mobile-debug-mcp 0.32.4 → 0.34.0
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
|
@@ -14,8 +14,18 @@ interface SnapshotState {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
let nextRevision = 1
|
|
17
|
+
const snapshotSessionId = crypto.randomUUID()
|
|
17
18
|
const snapshotStateByDevice = new Map<string, SnapshotState>()
|
|
18
19
|
|
|
20
|
+
export function getSnapshotSessionId() { return snapshotSessionId }
|
|
21
|
+
|
|
22
|
+
export function hasSnapshotBaseline(deviceKey: string, revision: number) {
|
|
23
|
+
const state = snapshotStateByDevice.get(deviceKey)
|
|
24
|
+
const capturedAt = state?.historyTimes.get(revision)
|
|
25
|
+
return !!state && capturedAt !== undefined && state.history.has(revision) &&
|
|
26
|
+
Date.now() - capturedAt <= 10 * 60 * 1000 && Date.now() - state.updatedAt <= 10 * 60 * 1000
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
function normalize(value: unknown): string {
|
|
20
30
|
if (value === null || value === undefined) return ''
|
|
21
31
|
return String(value).trim().toLowerCase()
|
|
@@ -163,7 +173,7 @@ export function deriveSnapshotMetadata(
|
|
|
163
173
|
const oversized = previous?.oversized ?? new Map<number, number>()
|
|
164
174
|
if (hasValidTree) {
|
|
165
175
|
history.set(revision, currentElements)
|
|
166
|
-
|
|
176
|
+
historyTimes.set(revision, Date.now())
|
|
167
177
|
if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
|
|
168
178
|
}
|
|
169
179
|
for (const [id, timestamp] of historyTimes) {
|
|
@@ -191,6 +201,7 @@ export function deriveSnapshotMetadata(
|
|
|
191
201
|
})
|
|
192
202
|
|
|
193
203
|
return {
|
|
204
|
+
snapshot_session_id: snapshotSessionId,
|
|
194
205
|
snapshot_revision: revision,
|
|
195
206
|
captured_at_ms: Date.now(),
|
|
196
207
|
snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { execCmd } from '../utils/exec.js'
|
|
2
|
+
import { getAdbCmd } from '../utils/android/utils.js'
|
|
3
|
+
|
|
4
|
+
export const TRACE_MAX_BYTES = 32 * 1024 * 1024
|
|
5
|
+
export const TRACE_CATEGORIES = ['sched', 'freq', 'idle', 'am', 'wm', 'gfx', 'view', 'input']
|
|
6
|
+
export interface TraceCapabilities {
|
|
7
|
+
supported: boolean; backend: 'perfetto'; device: { platform: string; id: string } | null
|
|
8
|
+
prerequisites: Record<string, boolean>; reasons: string[]; enabled_sources: string[]; unavailable_categories: string[]; limitations: string[]
|
|
9
|
+
}
|
|
10
|
+
export interface TraceProcess { pid: number; startTime: string }
|
|
11
|
+
export interface TraceNative {
|
|
12
|
+
capabilities(deviceId: string): Promise<TraceCapabilities>
|
|
13
|
+
start(deviceId: string, remote: string, duration: number, categories: string[]): Promise<void>
|
|
14
|
+
find(deviceId: string, remote: string): Promise<TraceProcess | null>
|
|
15
|
+
stop(deviceId: string, remote: string, owner: TraceProcess): Promise<void>
|
|
16
|
+
pull(deviceId: string, remote: string, local: string): Promise<void>
|
|
17
|
+
remove(deviceId: string, remote: string): Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Encode only this fixed preset using the public Perfetto proto field numbers:
|
|
21
|
+
// https://github.com/google/perfetto/tree/main/protos/perfetto/config
|
|
22
|
+
// Avoid --txt (explicitly unstable), arbitrary configuration and extra tooling.
|
|
23
|
+
function varint(n: number): Buffer {
|
|
24
|
+
const bytes: number[] = []
|
|
25
|
+
do { const byte = n % 128; n = Math.floor(n / 128); bytes.push(byte | (n ? 128 : 0)) } while (n)
|
|
26
|
+
return Buffer.from(bytes)
|
|
27
|
+
}
|
|
28
|
+
const uint = (field: number, n: number) => Buffer.concat([varint(field * 8), varint(n)])
|
|
29
|
+
const message = (field: number, value: Buffer | string) => {
|
|
30
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value)
|
|
31
|
+
return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes])
|
|
32
|
+
}
|
|
33
|
+
export function traceConfig(duration: number, categories: string[]): Buffer {
|
|
34
|
+
const ftrace = Buffer.concat(categories.map(cat => message(2, cat)))
|
|
35
|
+
const source = Buffer.concat([message(1, 'linux.ftrace'), message(100, ftrace)])
|
|
36
|
+
return Buffer.concat([
|
|
37
|
+
message(1, Buffer.concat([uint(1, 16384), uint(4, 1)])),
|
|
38
|
+
message(2, message(1, source)), uint(3, duration), uint(8, 1), uint(10, TRACE_MAX_BYTES)
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`
|
|
42
|
+
function ownedPath(remote: string) {
|
|
43
|
+
if (!/^\/data\/misc\/perfetto-traces\/mcp-[0-9a-f-]{36}\.perfetto-trace$/.test(remote)) throw new Error('Invalid owned trace path')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AndroidTraceNative implements TraceNative {
|
|
47
|
+
constructor(private execute: typeof execCmd = execCmd) {}
|
|
48
|
+
private async command(device: string, args: string[], timeout = 10000) {
|
|
49
|
+
const result = await this.execute(getAdbCmd(), ['-s', device, ...args], { timeout })
|
|
50
|
+
if (result.exitCode !== 0) throw new Error(result.stderr === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : 'ADB_TRACE_COMMAND_FAILED')
|
|
51
|
+
return result.stdout
|
|
52
|
+
}
|
|
53
|
+
private shell(device: string, script: string, timeout?: number) {
|
|
54
|
+
return this.command(device, ['shell', 'sh', '-c', quote(script)], timeout)
|
|
55
|
+
}
|
|
56
|
+
async capabilities(deviceId: string): Promise<TraceCapabilities> {
|
|
57
|
+
const checks: Record<string, boolean> = {}
|
|
58
|
+
const probe = async (name: string, fn: () => Promise<boolean>) => { try { checks[name] = await fn() } catch { checks[name] = false } }
|
|
59
|
+
let help = '', categories = ''
|
|
60
|
+
await probe('android_api_29', async () => Number(await this.command(deviceId, ['shell', 'getprop', 'ro.build.version.sdk'])) >= 29)
|
|
61
|
+
await probe('perfetto', async () => { const result = await this.execute(getAdbCmd(), ['-s', deviceId, 'shell', 'perfetto', '--help'], { timeout: 10000 }); help = result.stdout + result.stderr; return [0, 1].includes(result.exitCode!) && help.includes('Usage: perfetto') && help.includes('--background-wait') && help.includes('--config') })
|
|
62
|
+
await probe('tracing_service', async () => { await this.command(deviceId, ['shell', 'perfetto', '--query']); return true })
|
|
63
|
+
await probe('writable_storage', async () => { await this.shell(deviceId, 'test -w /data/misc/perfetto-traces'); return true })
|
|
64
|
+
await probe('process_identity', async () => { await this.shell(deviceId, 'command -v pidof >/dev/null && test -r /proc/self/stat && test -r /proc/self/cmdline'); return true })
|
|
65
|
+
await probe('base64', async () => (await this.shell(deviceId, "printf YQ== | base64 -d")) === 'a')
|
|
66
|
+
await probe('required_categories', async () => {
|
|
67
|
+
categories = await this.command(deviceId, ['shell', 'atrace', '--list_categories'])
|
|
68
|
+
return ['sched', 'input'].every(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories))
|
|
69
|
+
})
|
|
70
|
+
const enabled = TRACE_CATEGORIES.filter(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories))
|
|
71
|
+
return { supported: Object.values(checks).every(Boolean), backend: 'perfetto', device: { platform: 'android', id: deviceId },
|
|
72
|
+
prerequisites: checks, reasons: Object.keys(checks).filter(k => !checks[k]), enabled_sources: enabled,
|
|
73
|
+
unavailable_categories: TRACE_CATEGORIES.filter(c => !enabled.includes(c)), limitations: [
|
|
74
|
+
'Device-wide scheduling and system annotations may contain sensitive information.',
|
|
75
|
+
'Host times are not aligned with device trace clocks; source availability varies.',
|
|
76
|
+
'A server crash can leave a bounded trace file on the device for manual removal.'
|
|
77
|
+
] }
|
|
78
|
+
}
|
|
79
|
+
async start(device: string, remote: string, duration: number, categories: string[]) {
|
|
80
|
+
ownedPath(remote)
|
|
81
|
+
const encoded = traceConfig(duration, categories).toString('base64')
|
|
82
|
+
await this.shell(device, `umask 077; printf %s ${quote(encoded)} | base64 -d | perfetto --background-wait -c - -o ${quote(remote)}`, 35000)
|
|
83
|
+
}
|
|
84
|
+
// Scan only shell-owned readable processes. Exact output argv + executable name
|
|
85
|
+
// binds ownership even if start acknowledgement/PID output was lost.
|
|
86
|
+
async find(device: string, remote: string): Promise<TraceProcess | null> {
|
|
87
|
+
ownedPath(remote)
|
|
88
|
+
const output = await this.shell(device, `pids=$(pidof perfetto); rc=$?
|
|
89
|
+
[ "$rc" -le 1 ] || exit 1
|
|
90
|
+
for pid in $pids; do
|
|
91
|
+
p=/proc/$pid
|
|
92
|
+
[ -d "$p" ] || continue
|
|
93
|
+
[ -r "$p/cmdline" ] || { [ ! -d "$p" ] && continue; exit 1; }
|
|
94
|
+
cmd=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
|
|
95
|
+
printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || continue
|
|
96
|
+
first=$(printf '%s\\n' "$cmd" | head -n 1)
|
|
97
|
+
case "$first" in perfetto|/system/bin/perfetto) ;; *) continue ;; esac
|
|
98
|
+
stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
|
|
99
|
+
rest=\${stat##*) }
|
|
100
|
+
state=$(printf '%s\\n' "$rest" | awk '{print $1}')
|
|
101
|
+
[ "$state" = Z ] && continue
|
|
102
|
+
start=$(printf '%s\\n' "$rest" | awk '{print $20}')
|
|
103
|
+
printf '%s %s\\n' "\${p##*/}" "$start"
|
|
104
|
+
done`)
|
|
105
|
+
if (!output) return null
|
|
106
|
+
const match = /^(\d+) (\d+)$/.exec(output)
|
|
107
|
+
if (!match) throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE')
|
|
108
|
+
return { pid: Number(match[1]), startTime: match[2] }
|
|
109
|
+
}
|
|
110
|
+
async stop(device: string, remote: string, owner: TraceProcess) {
|
|
111
|
+
ownedPath(remote)
|
|
112
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || !/^\d+$/.test(owner.startTime)) throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE')
|
|
113
|
+
// Recheck start time and argv in the same shell immediately before signal.
|
|
114
|
+
await this.shell(device, `p=/proc/${owner.pid}
|
|
115
|
+
[ -d "$p" ] || exit 0
|
|
116
|
+
stat=$(cat "$p/stat") || exit 1
|
|
117
|
+
rest=\${stat##*) }
|
|
118
|
+
[ "$(printf '%s\\n' "$rest" | awk '{print $20}')" = ${quote(owner.startTime)} ] || exit 1
|
|
119
|
+
cmd=$(tr '\\000' '\\n' < "$p/cmdline") || exit 1
|
|
120
|
+
first=$(printf '%s\\n' "$cmd" | head -n 1)
|
|
121
|
+
case "$first" in perfetto|/system/bin/perfetto) ;; *) exit 1 ;; esac
|
|
122
|
+
printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || exit 1
|
|
123
|
+
kill -TERM ${owner.pid}`)
|
|
124
|
+
}
|
|
125
|
+
async pull(device: string, remote: string, local: string) {
|
|
126
|
+
ownedPath(remote)
|
|
127
|
+
// Reject oversized output before initiating transfer as well as afterwards.
|
|
128
|
+
const size = Number(await this.command(device, ['shell', 'stat', '-c', '%s', remote]))
|
|
129
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > TRACE_MAX_BYTES) throw new Error('TRACE_ARTIFACT_SIZE_INVALID')
|
|
130
|
+
await this.command(device, ['pull', remote, local], 30000)
|
|
131
|
+
}
|
|
132
|
+
async remove(device: string, remote: string) { ownedPath(remote); await this.command(device, ['shell', 'rm', '-f', remote]) }
|
|
133
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { AndroidTraceNative, TRACE_MAX_BYTES, type TraceCapabilities, type TraceNative, type TraceProcess } from './system-trace-android.js'
|
|
6
|
+
import { evidenceSessionId } from './evidence.js'
|
|
7
|
+
import { runWithBudget, remainingBudget } from '../utils/operation-budget.js'
|
|
8
|
+
|
|
9
|
+
export class TraceError extends Error {
|
|
10
|
+
constructor(public code: string) { super(code) }
|
|
11
|
+
}
|
|
12
|
+
type State = 'starting' | 'recording' | 'stopping' | 'completed' | 'cancelled' | 'failed'
|
|
13
|
+
type Completion = 'requested' | 'duration_limit' | 'cancelled' | 'error'
|
|
14
|
+
export interface TraceResult {
|
|
15
|
+
trace_id: string; session_id: string; device: { platform: 'android'; id: string }; backend: 'perfetto'
|
|
16
|
+
state: State; reason: string | null; completion_reason: Completion | null
|
|
17
|
+
capture_started_at_ms: number | null; capture_completed_at_ms: number | null
|
|
18
|
+
artifact: { path: string; format: 'perfetto'; size_bytes: number } | null
|
|
19
|
+
cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }
|
|
20
|
+
correlation_ids: string[]; enabled_sources: string[]; unavailable_categories: string[]
|
|
21
|
+
}
|
|
22
|
+
interface Session {
|
|
23
|
+
result: TraceResult; remote: string; owner: TraceProcess | null; stopped: boolean; discard: boolean
|
|
24
|
+
timer?: ReturnType<typeof setTimeout>; terminalAt?: number; deadline: number; local?: string
|
|
25
|
+
queue: Promise<unknown>; linked: Set<string>; partial: Set<string>
|
|
26
|
+
}
|
|
27
|
+
export interface TraceClock {
|
|
28
|
+
wall(): number; mono(): number; sleep(ms: number): Promise<void>
|
|
29
|
+
schedule(fn: () => void, ms: number): ReturnType<typeof setTimeout>
|
|
30
|
+
clear(timer: ReturnType<typeof setTimeout>): void
|
|
31
|
+
}
|
|
32
|
+
const clock: TraceClock = {
|
|
33
|
+
wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
34
|
+
schedule: (fn, ms) => { const t = setTimeout(fn, ms); t.unref(); return t }, clear: clearTimeout
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SystemTraceSessions {
|
|
38
|
+
private sessions = new Map<string, Session>()
|
|
39
|
+
private closing = false
|
|
40
|
+
constructor(private native: TraceNative = new AndroidTraceNative(), private time: TraceClock = clock) {}
|
|
41
|
+
private prune() {
|
|
42
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.result.cleanup.status === 'complete')
|
|
43
|
+
for (const s of terminal) if (this.time.mono() - s.terminalAt! >= 600000) this.sessions.delete(s.result.trace_id)
|
|
44
|
+
const remaining = terminal.filter(s => this.sessions.has(s.result.trace_id))
|
|
45
|
+
for (const s of remaining.slice(0, Math.max(0, remaining.length - 100))) this.sessions.delete(s.result.trace_id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private get(id: string) { this.prune(); const s = this.sessions.get(id); if (!s) throw new TraceError('TRACE_NOT_FOUND'); return s }
|
|
49
|
+
private snapshot(s: Session): TraceResult { return structuredClone(s.result) }
|
|
50
|
+
private serial<T>(s: Session, fn: () => Promise<T>): Promise<T> {
|
|
51
|
+
const result = s.queue.then(fn)
|
|
52
|
+
s.queue = result.catch(() => {})
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
async capabilities(device: string) { return this.native.capabilities(device) }
|
|
56
|
+
async start(device: string, duration = 30000): Promise<TraceResult> {
|
|
57
|
+
if (!Number.isInteger(duration) || duration < 1000 || duration > 120000) throw new TraceError('INVALID_ARGUMENT')
|
|
58
|
+
this.prune()
|
|
59
|
+
const reserved = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete')
|
|
60
|
+
if (this.closing || reserved.length >= 4 || reserved.some(s => s.result.device.id === device)) throw new TraceError('TRACE_BUSY')
|
|
61
|
+
const id = randomUUID()
|
|
62
|
+
const result: TraceResult = { trace_id: id, session_id: evidenceSessionId(), device: { platform: 'android', id: device }, backend: 'perfetto',
|
|
63
|
+
state: 'starting', reason: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
|
|
64
|
+
artifact: null, cleanup: { status: 'pending', remaining_resources: [] }, correlation_ids: [], enabled_sources: [], unavailable_categories: [] }
|
|
65
|
+
const s: Session = { result, remote: `/data/misc/perfetto-traces/mcp-${id}.perfetto-trace`, owner: null, stopped: true,
|
|
66
|
+
discard: false, deadline: Infinity, queue: Promise.resolve(), linked: new Set(), partial: new Set() }
|
|
67
|
+
this.sessions.set(id, s)
|
|
68
|
+
return this.serial(s, async () => {
|
|
69
|
+
let cap: TraceCapabilities
|
|
70
|
+
try { cap = await this.native.capabilities(device) }
|
|
71
|
+
catch { this.fail(s, 'TRACE_UNSUPPORTED'); this.clean(s); return this.snapshot(s) }
|
|
72
|
+
s.result.enabled_sources = cap.enabled_sources
|
|
73
|
+
s.result.unavailable_categories = cap.unavailable_categories
|
|
74
|
+
if (!cap.supported || this.closing) { this.fail(s, this.closing ? 'TRACE_BUSY' : 'TRACE_UNSUPPORTED'); this.clean(s); return this.snapshot(s) }
|
|
75
|
+
s.stopped = false
|
|
76
|
+
s.result.cleanup.remaining_resources = [s.remote, 'device_capture']
|
|
77
|
+
// Conservative host bounds include acknowledgement latency, not exact device start.
|
|
78
|
+
s.result.capture_started_at_ms = this.time.wall()
|
|
79
|
+
s.deadline = this.time.mono() + duration
|
|
80
|
+
try {
|
|
81
|
+
await this.native.start(device, s.remote, duration, cap.enabled_sources)
|
|
82
|
+
s.owner = await this.native.find(device, s.remote)
|
|
83
|
+
s.result.state = 'recording'
|
|
84
|
+
s.timer = this.time.schedule(() => { void this.control(id, 'duration').catch(() => {}) }, Math.max(0, s.deadline - this.time.mono()))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
s.discard = true
|
|
87
|
+
this.fail(s, this.code(error, 'TRACE_START_FAILED'))
|
|
88
|
+
try { await this.halt(s); await this.remove(s) } catch { s.result.cleanup.status = 'failed' }
|
|
89
|
+
}
|
|
90
|
+
return this.snapshot(s)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
private code(error: unknown, fallback: string) { return error instanceof Error && error.message === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : fallback }
|
|
94
|
+
private fail(s: Session, reason: string) {
|
|
95
|
+
s.result.state = 'failed'; s.result.reason = reason; s.result.completion_reason = 'error'
|
|
96
|
+
s.terminalAt = this.time.mono()
|
|
97
|
+
}
|
|
98
|
+
private clean(s: Session) {
|
|
99
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] }
|
|
100
|
+
s.terminalAt = this.time.mono()
|
|
101
|
+
this.prune()
|
|
102
|
+
}
|
|
103
|
+
private async halt(s: Session) {
|
|
104
|
+
if (s.stopped) return
|
|
105
|
+
const deadline = this.time.mono() + 10000
|
|
106
|
+
await runWithBudget(10000, async () => {
|
|
107
|
+
const found = await this.native.find(s.result.device.id, s.remote)
|
|
108
|
+
if (found) {
|
|
109
|
+
if (s.owner && (s.owner.pid !== found.pid || s.owner.startTime !== found.startTime)) throw new TraceError('TRACE_STOP_FAILED')
|
|
110
|
+
s.owner ??= found
|
|
111
|
+
try { await this.native.stop(s.result.device.id, s.remote, s.owner) }
|
|
112
|
+
catch (error) {
|
|
113
|
+
// Natural expiry can win between identity lookup and SIGTERM. Only
|
|
114
|
+
// confirmed absence permits finalisation; unreadable/reused identity fails.
|
|
115
|
+
if (await this.native.find(s.result.device.id, s.remote)) throw error
|
|
116
|
+
}
|
|
117
|
+
while (await this.native.find(s.result.device.id, s.remote)) {
|
|
118
|
+
if (this.time.mono() >= deadline) throw new TraceError('ACTION_TIMEOUT')
|
|
119
|
+
remainingBudget(); await this.time.sleep(100)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
s.stopped = true
|
|
124
|
+
s.result.capture_completed_at_ms = this.time.wall()
|
|
125
|
+
s.result.cleanup.remaining_resources = [s.remote]
|
|
126
|
+
for (const linked of s.linked) s.partial.add(linked)
|
|
127
|
+
}
|
|
128
|
+
private async remove(s: Session) {
|
|
129
|
+
if (!s.stopped) throw new TraceError('TRACE_STOP_FAILED')
|
|
130
|
+
await this.native.remove(s.result.device.id, s.remote)
|
|
131
|
+
if (s.local && !s.result.artifact) { await fs.rm(s.local, { recursive: true, force: true }); s.local = undefined }
|
|
132
|
+
this.clean(s)
|
|
133
|
+
}
|
|
134
|
+
private async artifact(s: Session) {
|
|
135
|
+
if (s.result.artifact) return
|
|
136
|
+
s.local ??= await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-trace-'))
|
|
137
|
+
const local = s.local
|
|
138
|
+
const partial = path.join(local, 'capture.partial')
|
|
139
|
+
try {
|
|
140
|
+
await fs.chmod(s.local, 0o700)
|
|
141
|
+
await this.native.pull(s.result.device.id, s.remote, partial)
|
|
142
|
+
const stat = await fs.stat(partial)
|
|
143
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > TRACE_MAX_BYTES) throw new TraceError('TRACE_ARTIFACT_FAILED')
|
|
144
|
+
await fs.chmod(partial, 0o600)
|
|
145
|
+
const target = path.join(s.local, `${s.result.trace_id}.perfetto-trace`)
|
|
146
|
+
await fs.rename(partial, target)
|
|
147
|
+
s.result.artifact = { path: target, format: 'perfetto', size_bytes: stat.size }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
try { await fs.rm(local, { recursive: true, force: true }); s.local = undefined }
|
|
150
|
+
catch { s.result.cleanup.remaining_resources = [s.remote, local] }
|
|
151
|
+
throw error
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async control(id: string, operation: 'status' | 'stop' | 'cancel' | 'duration'): Promise<TraceResult> {
|
|
155
|
+
const s = this.get(id)
|
|
156
|
+
return this.serial(s, async () => {
|
|
157
|
+
if (s.result.cleanup.status === 'complete') return this.snapshot(s)
|
|
158
|
+
if (operation === 'status' && s.result.state === 'recording') {
|
|
159
|
+
try {
|
|
160
|
+
if (await this.native.find(s.result.device.id, s.remote) && this.time.mono() < s.deadline) return this.snapshot(s)
|
|
161
|
+
} catch (error) { this.fail(s, this.code(error, 'TRACE_STOP_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
|
|
162
|
+
}
|
|
163
|
+
if (s.timer) { this.time.clear(s.timer); s.timer = undefined }
|
|
164
|
+
if (operation === 'cancel') s.discard = true
|
|
165
|
+
const hadStartFailure = s.discard && s.result.state === 'failed' && operation !== 'cancel'
|
|
166
|
+
s.result.state = 'stopping'
|
|
167
|
+
s.result.cleanup.status = 'pending'
|
|
168
|
+
try { await this.halt(s) }
|
|
169
|
+
catch (error) { this.fail(s, this.code(error, 'TRACE_STOP_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
|
|
170
|
+
if (!s.discard) {
|
|
171
|
+
try { await this.artifact(s) }
|
|
172
|
+
catch (error) { this.fail(s, this.code(error, 'TRACE_ARTIFACT_FAILED')); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
|
|
173
|
+
}
|
|
174
|
+
try { await this.remove(s) }
|
|
175
|
+
catch { this.fail(s, 'TRACE_STOP_FAILED'); s.result.cleanup.status = 'failed'; return this.snapshot(s) }
|
|
176
|
+
if (hadStartFailure) s.result.state = 'failed'
|
|
177
|
+
else {
|
|
178
|
+
s.result.state = s.discard ? 'cancelled' : 'completed'
|
|
179
|
+
s.result.reason = s.discard ? 'TRACE_CANCELLED' : null
|
|
180
|
+
s.result.completion_reason = s.discard ? 'cancelled' : operation === 'duration' || this.time.mono() >= s.deadline ? 'duration_limit' : 'requested'
|
|
181
|
+
}
|
|
182
|
+
return this.snapshot(s)
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
async link(id: string, device: { platform: string; id: string }, correlation: string) {
|
|
186
|
+
const s = this.get(id)
|
|
187
|
+
if (device.platform !== 'android' || device.id !== s.result.device.id) throw new TraceError('TRACE_NOT_FOUND')
|
|
188
|
+
const status = await this.control(id, 'status')
|
|
189
|
+
if (device.platform !== 'android' || device.id !== status.device.id || status.state !== 'recording') throw new TraceError('TRACE_NOT_FOUND')
|
|
190
|
+
// Validate again after the asynchronous status call: stop/cancel may have won.
|
|
191
|
+
return this.serial(s, async () => {
|
|
192
|
+
if (s.result.state !== 'recording' || this.time.mono() >= s.deadline) throw new TraceError('TRACE_NOT_FOUND')
|
|
193
|
+
if (!s.result.correlation_ids.includes(correlation)) {
|
|
194
|
+
if (s.result.correlation_ids.length >= 1000) throw new TraceError('TRACE_BUSY')
|
|
195
|
+
s.result.correlation_ids.push(correlation)
|
|
196
|
+
}
|
|
197
|
+
s.linked.add(correlation)
|
|
198
|
+
let finish: Promise<{ trace_id: string; coverage: string; clock_alignment: string }> | undefined
|
|
199
|
+
return () => finish ??= (async () => {
|
|
200
|
+
let recording = false
|
|
201
|
+
try { recording = (await this.control(id, 'status')).state === 'recording' }
|
|
202
|
+
catch { /* Unavailable state cannot establish complete coverage. */ }
|
|
203
|
+
s.linked.delete(correlation)
|
|
204
|
+
return { trace_id: id, coverage: !recording || s.partial.has(correlation) || this.time.mono() >= s.deadline ? 'partial' : 'covered',
|
|
205
|
+
clock_alignment: 'unknown' }
|
|
206
|
+
})()
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
async shutdown() {
|
|
210
|
+
this.closing = true
|
|
211
|
+
// Independent device cleanup in parallel under one overall deadline.
|
|
212
|
+
return runWithBudget(10000, async () => {
|
|
213
|
+
await Promise.allSettled([...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.control(s.result.trace_id, 'cancel')))
|
|
214
|
+
return [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s))
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export const systemTraces = new SystemTraceSessions()
|
package/src/server/common.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currentEvidenceActionId } from '../observe/evidence.js'
|
|
1
2
|
import { recordTiming } from '../utils/timing.js'
|
|
2
3
|
import type {
|
|
3
4
|
ActionTrace,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
TraceStep
|
|
12
13
|
} from '../types.js'
|
|
13
14
|
import { ToolsObserve } from '../observe/index.js'
|
|
15
|
+
import { isConditionBlocked } from '../interact/conditional-action.js'
|
|
14
16
|
|
|
15
17
|
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
|
|
16
18
|
export const DEFAULT_MAX_RETRY_DEPTH = 3
|
|
@@ -87,11 +89,14 @@ export function getArrayArg<T>(args: ToolCallArgs, key: string): T[] | undefined
|
|
|
87
89
|
let actionSequence = 0
|
|
88
90
|
|
|
89
91
|
export function nextActionId(actionType: string, timestamp: number) {
|
|
92
|
+
const current = currentEvidenceActionId()
|
|
93
|
+
if (current) return current
|
|
90
94
|
actionSequence += 1
|
|
91
95
|
return `${actionType}_${timestamp}_${actionSequence}`
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export async function captureActionFingerprint(platform?: 'android' | 'ios', deviceId?: string): Promise<string | null> {
|
|
99
|
+
if (isConditionBlocked()) return null
|
|
95
100
|
if (!platform) return null
|
|
96
101
|
try {
|
|
97
102
|
const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
|
|
@@ -317,6 +322,8 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
|
|
|
317
322
|
case 'STALE_REFERENCE':
|
|
318
323
|
return 'TargetResolutionFailure'
|
|
319
324
|
case 'ELEMENT_NOT_INTERACTABLE':
|
|
325
|
+
case 'PRECONDITION_FAILED':
|
|
326
|
+
case 'PRECONDITION_UNAVAILABLE':
|
|
320
327
|
case 'ACTION_TIMEOUT':
|
|
321
328
|
return 'ExecutionFailure'
|
|
322
329
|
case 'TIMEOUT':
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const conditionalTools = new Set(['tap', 'tap_element', 'swipe', 'type_text', 'press_back'])
|
|
4
|
+
export const expectedStateSchema = z.object({
|
|
5
|
+
snapshotSessionId: z.string().min(1),
|
|
6
|
+
snapshotRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
7
|
+
timeoutMs: z.number().int().min(100).max(60000).optional()
|
|
8
|
+
}).strict()
|
|
9
|
+
|
|
10
|
+
export type ExpectedState = z.infer<typeof expectedStateSchema>
|
|
11
|
+
|
|
12
|
+
export const expectedStateJson = {
|
|
13
|
+
type: 'object', additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
snapshotSessionId: { type: 'string', minLength: 1 },
|
|
16
|
+
snapshotRevision: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
|
|
17
|
+
timeoutMs: { type: 'integer', minimum: 100, maximum: 60000, default: 15000 }
|
|
18
|
+
},
|
|
19
|
+
required: ['snapshotSessionId', 'snapshotRevision']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateExpectedState(name: string, args: Record<string, unknown>): ExpectedState | undefined {
|
|
23
|
+
if (args.expectedState === undefined) return undefined
|
|
24
|
+
if (!conditionalTools.has(name)) throw new Error('INVALID_ARGUMENT: expectedState is not supported on this tool')
|
|
25
|
+
const parsed = expectedStateSchema.safeParse(args.expectedState)
|
|
26
|
+
if (!parsed.success) throw new Error('INVALID_ARGUMENT: invalid expectedState')
|
|
27
|
+
if ((args.platform !== 'android' && args.platform !== 'ios') ||
|
|
28
|
+
typeof args.deviceId !== 'string' || !args.deviceId.trim() ||
|
|
29
|
+
['booted', 'default'].includes(args.deviceId)) throw new Error('INVALID_ARGUMENT: expectedState requires an explicit platform and concrete deviceId')
|
|
30
|
+
if (['type_text', 'press_back'].includes(name) && args.platform !== 'android') throw new Error('INVALID_ARGUMENT: this action supports only Android')
|
|
31
|
+
return parsed.data
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { debuggerContracts } from './debugger-schema.js'
|
|
2
|
+
import { debuggerSessions, debuggerCapabilities } from '../debugger/sessions.js'
|
|
3
|
+
import { DebuggerError, withDebuggerBudget } from '../debugger/worker.js'
|
|
4
|
+
import { wrapResponse, type ToolCallArgs } from './common.js'
|
|
5
|
+
function failure(code: string, summary: Record<string, unknown> = {}) {
|
|
6
|
+
return { ...wrapResponse({ debug_session_id: null, device: null, target: null, state: null,
|
|
7
|
+
captured_at_ms: Date.now(), capabilities: debuggerCapabilities, limitations: [], cleanup: null,
|
|
8
|
+
...summary, success: false, error: { code, message: code, retryable: false } }), isError: true }
|
|
9
|
+
}
|
|
10
|
+
export async function handleDebugger(name: string, args: ToolCallArgs, signal?: AbortSignal) {
|
|
11
|
+
const parsed = debuggerContracts[name].schema.safeParse(args)
|
|
12
|
+
if (!parsed.success) return failure('INVALID_ARGUMENT')
|
|
13
|
+
const a = parsed.data as Record<string, any>
|
|
14
|
+
const command = debuggerContracts[name].command
|
|
15
|
+
try {
|
|
16
|
+
if (signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
17
|
+
const result = await withDebuggerBudget(a.timeoutMs, signal, async () => command === 'capabilities' ? await debuggerSessions.capabilities(a.deviceId, a.appId, a.processId) :
|
|
18
|
+
command === 'attach' ? await debuggerSessions.attach(a.deviceId, a.appId, a.processId, a.timeoutMs) :
|
|
19
|
+
await debuggerSessions.command(a.debugSessionId, command, { ...a, signal: command === 'events' ? signal : undefined }, a.timeoutMs))
|
|
20
|
+
if (signal?.aborted && command === 'capabilities') throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
21
|
+
return { ...wrapResponse(result), ...(!result.success ? { isError: true } : {}) }
|
|
22
|
+
} catch (e) {
|
|
23
|
+
const code = e instanceof DebuggerError ? e.code : 'DEBUGGER_WORKER_FAILED'
|
|
24
|
+
let summary: any = {}
|
|
25
|
+
if (a.debugSessionId) { try { summary = debuggerSessions.summary(a.debugSessionId) } catch { /* Unknown session */ } }
|
|
26
|
+
return failure(code, { debug_session_id: a.debugSessionId ?? null,
|
|
27
|
+
device: a.deviceId ? { platform: 'android', id: a.deviceId } : null, ...summary })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
const text = z.string().min(1).max(512)
|
|
3
|
+
const integer = z.number().int().safe().positive()
|
|
4
|
+
const timeout = z.number().int().min(100).max(30000)
|
|
5
|
+
const target = { platform: z.literal('android'), deviceId: text, appId: text.regex(/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/), processId: integer.optional() }
|
|
6
|
+
const session = { debugSessionId: text }
|
|
7
|
+
const stop = { ...session, stopId: text }
|
|
8
|
+
const specs = {
|
|
9
|
+
get_debugger_capabilities: { fields: target, command: 'capabilities', description: 'Probe optional Android JDK/JDWP prerequisites and matching running debuggable processes; does not attach.' },
|
|
10
|
+
attach_debugger: { fields: target, command: 'attach', description: 'Attach an owned Android JDI session. Requires an explicit device and debuggable app; choose processId when ambiguous. Stops suspend all app threads. Auto-detach: 60s paused, 5m idle, 30m total.' },
|
|
11
|
+
get_debugger_state: { fields: session, command: 'state', description: 'Read debugger state, current stop and breakpoint/cleanup status.' },
|
|
12
|
+
set_debugger_breakpoint: { fields: { ...session, className: text.regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/), line: integer }, command: 'set', description: 'Set an exact binary-class and executable-source-line breakpoint; unloaded classes remain pending. No silent line relocation.' },
|
|
13
|
+
remove_debugger_breakpoint: { fields: { ...session, breakpointId: text }, command: 'remove', description: 'Remove an owned breakpoint; repeated removal succeeds.' },
|
|
14
|
+
wait_for_debugger_event: { fields: { ...session, afterSequence: z.number().int().safe().min(0).optional() }, command: 'events', description: 'Wait for bounded debugger events after a sequence cursor. Empty timeout does not affect execution; overflow is explicit.' },
|
|
15
|
+
get_debugger_stack: { fields: { ...stop, threadId: text, offset: z.number().int().min(0).max(999).optional(), limit: z.number().int().min(1).max(100).optional() }, command: 'stack', description: 'Read bounded stack frames for a stopped thread; IDs expire on resume.' },
|
|
16
|
+
get_debugger_locals: { fields: { ...stop, frameId: text }, command: 'locals', description: 'Read at most 100 shallow locals. Sensitive values returned only on request; strings truncate at 256 characters. No evaluation or field traversal.' },
|
|
17
|
+
step_debugger: { fields: { ...stop, threadId: text, kind: z.enum(['into', 'over', 'out']) }, command: 'step', description: 'Acknowledge one-shot line stepping and resume. Wait for a subsequent stop event separately.' },
|
|
18
|
+
resume_debugger: { fields: stop, command: 'resume', description: 'Resume owned debugger suspension and immediately expire stopped references.' },
|
|
19
|
+
detach_debugger: { fields: session, command: 'detach', description: 'Dispose the owned debugger connection, release owned suspension and remove its exact forward. Idempotent; retries incomplete cleanup.' }
|
|
20
|
+
}
|
|
21
|
+
// Derive JSON schemas and runtime validation from the same small Zod field vocabulary.
|
|
22
|
+
function jsonField(schema: z.ZodTypeAny): any {
|
|
23
|
+
if (schema instanceof z.ZodOptional) return jsonField(schema.unwrap())
|
|
24
|
+
if (schema instanceof z.ZodLiteral) return { type: 'string', enum: [schema.value] }
|
|
25
|
+
if (schema instanceof z.ZodEnum) return { type: 'string', enum: schema.options }
|
|
26
|
+
if (schema instanceof z.ZodString) return { type: 'string', minLength: 1, maxLength: 512, ...Object.assign({}, ...schema._def.checks.filter(c => c.kind === 'regex').map(c => ({ pattern: (c as any).regex.source }))) }
|
|
27
|
+
if (schema instanceof z.ZodNumber) return { type: 'integer', minimum: schema.minValue, maximum: schema.maxValue }
|
|
28
|
+
throw new Error('Unsupported debugger field schema')
|
|
29
|
+
}
|
|
30
|
+
export const debuggerContracts = Object.fromEntries(Object.entries(specs).map(([name, spec]) => [name, {
|
|
31
|
+
...spec, schema: z.object({ ...spec.fields, timeoutMs: timeout.default(name === 'wait_for_debugger_event' ? 1000 : 10000) }).strict()
|
|
32
|
+
}]))
|
|
33
|
+
export const debuggerTools = Object.entries(debuggerContracts).map(([name, spec]) => ({ name, description: spec.description,
|
|
34
|
+
inputSchema: { type: 'object', additionalProperties: false,
|
|
35
|
+
required: Object.entries(spec.fields).filter(([, field]) => !field.isOptional()).map(([key]) => key),
|
|
36
|
+
properties: { ...Object.fromEntries(Object.entries(spec.fields).map(([key, field]) => [key, jsonField(field)])), timeoutMs: { type: 'integer', minimum: 100, maximum: 30000, default: name === 'wait_for_debugger_event' ? 1000 : 10000 } } }
|
|
37
|
+
}))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { expectedStateSchema, expectedStateJson } from './condition-schema.js'
|
|
2
3
|
|
|
3
4
|
const selector = z.object({
|
|
4
5
|
text: z.string().min(1).optional(), resource_id: z.string().min(1).optional(),
|
|
@@ -23,7 +24,7 @@ const assertion = z.union([
|
|
|
23
24
|
const id = z.string().min(1)
|
|
24
25
|
const step = z.union([
|
|
25
26
|
z.object({ id, type: z.literal('start_app'), appId: z.string().min(1), verificationMode: verificationMode.optional() }).strict(),
|
|
26
|
-
z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional() }).strict().refine(exactlyOneTarget),
|
|
27
|
+
z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional(), expectedState: expectedStateSchema.optional() }).strict().refine(exactlyOneTarget),
|
|
27
28
|
z.object({ id, type: z.literal('wait'), selector, condition: z.enum(['exists', 'not_exists', 'visible', 'clickable']).optional(), timeoutMs, pollIntervalMs }).strict(),
|
|
28
29
|
z.object({ id, type: z.literal('assert'), assertion, timeoutMs, pollIntervalMs }).strict()
|
|
29
30
|
])
|
|
@@ -33,6 +34,7 @@ export const tapElementControlsSchema = z.object({
|
|
|
33
34
|
|
|
34
35
|
export const journeySchema = z.object({
|
|
35
36
|
platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
|
|
37
|
+
systemTraceId: z.string().uuid().optional(),
|
|
36
38
|
responseMode: z.enum(['compact', 'debug']).default('compact'), captureOnFailure: z.boolean().default(false),
|
|
37
39
|
defaults: z.object({ verificationMode: verificationMode.optional(), actionTimeoutMs: z.number().int().finite().optional(), verificationTimeoutMs: z.number().int().finite().optional() }).strict().optional(),
|
|
38
40
|
steps: z.array(step).min(1).max(50)
|
|
@@ -49,7 +51,7 @@ const objectJson = (properties: Record<string, unknown>, required: string[], ext
|
|
|
49
51
|
export const journeyStepsJson = {
|
|
50
52
|
type: 'array', minItems: 1, maxItems: 50, items: { oneOf: [
|
|
51
53
|
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'start_app' }, appId: { type: 'string', minLength: 1 }, verificationMode: modeJson }, ['id', 'type', 'appId']),
|
|
52
|
-
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson,
|
|
54
|
+
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson, expectedState: expectedStateJson,
|
|
53
55
|
waitFor: objectJson({ ...intervalJson, timeoutMs: { type: 'integer', minimum: 100, maximum: 10000 }, condition: { enum: ['exists', 'visible', 'clickable'] }, match: objectJson({ index: { type: 'integer', minimum: 0 } }, []) }, [])
|
|
54
56
|
}, ['id', 'type'], { oneOf: oneTargetJson }),
|
|
55
57
|
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'wait' }, selector: selectorJson, condition: { enum: ['exists', 'not_exists', 'visible', 'clickable'] }, ...intervalJson }, ['id', 'type', 'selector']),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
const timeout = z.number().int().min(100).max(60000)
|
|
3
|
+
export const readinessSchema = z.object({
|
|
4
|
+
platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
|
|
5
|
+
timeoutMs: timeout.optional(), stabilityMs: z.number().int().min(100).max(10000).optional()
|
|
6
|
+
}).strict()
|
|
7
|
+
export const readinessWaitSchema = readinessSchema.extend({ pollIntervalMs: z.number().int().min(100).max(1000).optional() }).strict()
|
|
8
|
+
export const readinessPropertiesJson = {
|
|
9
|
+
platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 },
|
|
10
|
+
timeoutMs: { type: 'integer', minimum: 100, maximum: 60000 },
|
|
11
|
+
stabilityMs: { type: 'integer', minimum: 100, maximum: 10000, default: 500 }
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
const target = { platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional() }
|
|
3
|
+
export const traceTargetSchema = z.object(target).strict()
|
|
4
|
+
export const traceStartSchema = z.object({ ...target, durationMs: z.number().int().min(1000).max(120000).default(30000) }).strict()
|
|
5
|
+
export const traceControlSchema = z.object({ traceId: z.string().uuid() }).strict()
|
|
6
|
+
const properties = { platform: { type: 'string', enum: ['android', 'ios'] }, deviceId: { type: 'string', minLength: 1 } }
|
|
7
|
+
export const systemTraceTools = [
|
|
8
|
+
{ name: 'get_system_trace_capabilities', description: 'Probe Android Perfetto prerequisites without recording. Device-wide system tracing is optional; iOS is unsupported.',
|
|
9
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['platform'], properties } },
|
|
10
|
+
{ name: 'start_system_trace', description: 'Start bounded Android Perfetto recording, returning an owned trace_id. Run an existing journey with systemTraceId, then stop to retrieve a local artifact or cancel to discard. Device-wide traces may contain sensitive system annotations; no upload.',
|
|
11
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['platform'], properties: { ...properties, durationMs: { type: 'integer', minimum: 1000, maximum: 120000, default: 30000 } } } },
|
|
12
|
+
...['get_system_trace_status', 'stop_system_trace', 'cancel_system_trace'].map(name => ({ name,
|
|
13
|
+
description: name === 'get_system_trace_status' ? 'Reconcile an owned trace and retrieve its state; finalise duration-completed capture.' :
|
|
14
|
+
name === 'stop_system_trace' ? 'Stop owned capture gracefully and retrieve a local Perfetto artifact. Retry after a transfer/cleanup failure.' : 'Stop owned capture and discard partial files. Already completed local artifacts are retained.',
|
|
15
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['traceId'], properties: { traceId: { type: 'string', format: 'uuid' } } }
|
|
16
|
+
}))
|
|
17
|
+
]
|