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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { registerDebuggerPauseProvider } from './guard.js'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js'
|
|
4
|
+
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js'
|
|
5
|
+
|
|
6
|
+
type State = 'attaching' | 'running' | 'paused' | 'detaching' | 'detached' | 'disconnected' | 'failed'
|
|
7
|
+
interface Session {
|
|
8
|
+
id: string; device: string; target: DebugTarget | null; state: State; stop: any; reason: string | null
|
|
9
|
+
worker?: DebugWorker; port?: number; queue: Promise<unknown>; events: any[]; sequence: number
|
|
10
|
+
cleanup: { status: 'pending' | 'complete' | 'failed'; remaining_resources: string[] }; terminalAt?: number
|
|
11
|
+
created: number; activity: number; pausedAt?: number; timer?: ReturnType<typeof setInterval>; cleaning?: Promise<void>
|
|
12
|
+
}
|
|
13
|
+
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
14
|
+
evaluation: false, mutation: false, coroutines: false, native: false }
|
|
15
|
+
const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD']
|
|
16
|
+
export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
|
|
17
|
+
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null
|
|
18
|
+
}
|
|
19
|
+
export class DebuggerSessions {
|
|
20
|
+
private sessions = new Map<string, Session>()
|
|
21
|
+
private closing = false
|
|
22
|
+
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) {}
|
|
23
|
+
private prune() {
|
|
24
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete')
|
|
25
|
+
for (const s of terminal) if (Date.now() - s.terminalAt! >= 600000) this.sessions.delete(s.id)
|
|
26
|
+
const remaining = terminal.filter(s => this.sessions.has(s.id))
|
|
27
|
+
for (const s of remaining.slice(0, Math.max(0, remaining.length - 100))) this.sessions.delete(s.id)
|
|
28
|
+
}
|
|
29
|
+
private get(id: string) { this.prune(); const s = this.sessions.get(id); if (!s) throw new DebuggerError('DEBUGGER_NOT_FOUND'); s.activity = Date.now(); return s }
|
|
30
|
+
private serial<T>(s: Session, fn: () => Promise<T>) { const out = s.queue.then(fn); s.queue = out.catch(() => {}); return out }
|
|
31
|
+
private result(s: Session, payload: any = {}) {
|
|
32
|
+
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed')
|
|
33
|
+
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED'
|
|
34
|
+
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false } } : {}), debug_session_id: s.id,
|
|
35
|
+
device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
36
|
+
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload })
|
|
37
|
+
}
|
|
38
|
+
summary(id: string) { return this.result(this.get(id)) }
|
|
39
|
+
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete') }
|
|
40
|
+
paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null }
|
|
41
|
+
assertRunning(device: string) { if (this.paused(device)) throw new DebuggerError('DEBUGGER_PAUSED') }
|
|
42
|
+
private event(s: Session, event: WorkerEvent) {
|
|
43
|
+
if (s.cleanup.status === 'complete') return
|
|
44
|
+
if (event.event === 'stopped' && !s.cleaning) { s.state = 'paused'; s.stop = event; s.pausedAt ??= Date.now() }
|
|
45
|
+
if (event.event === 'terminated' || event.event === 'worker_failed') {
|
|
46
|
+
if (!s.cleaning && s.state !== 'failed') s.reason = event.reason ?? 'worker_failed'
|
|
47
|
+
if (!s.cleaning) {
|
|
48
|
+
s.state = event.event === 'worker_failed' ? 'failed' : event.reason === 'process_exit' || event.reason === 'transport_lost' ? 'disconnected' : 'detached'
|
|
49
|
+
s.stop = null
|
|
50
|
+
// The worker has disposed or lost its transport. Cleanup remains pending until host resources are checked.
|
|
51
|
+
void this.serial(s, () => this.cleanup(s, false))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
s.events.push({ ...event, sequence: ++s.sequence, received_at_ms: Date.now() })
|
|
55
|
+
if (s.events.length > 256) s.events.shift()
|
|
56
|
+
}
|
|
57
|
+
async capabilities(device: string, app: string, pid?: number) {
|
|
58
|
+
const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false }
|
|
59
|
+
let candidates: DebugTarget[] = []
|
|
60
|
+
const reasons: string[] = []
|
|
61
|
+
try { await this.prerequisite(); prerequisites.jdk = true } catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push('DEBUGGER_UNSUPPORTED') }
|
|
62
|
+
try { candidates = await this.native.candidates(device, app); prerequisites.device = true; prerequisites.process = candidates.some(t => pid === undefined || pid === t.pid); if (!prerequisites.process) reasons.push('PROCESS_NOT_FOUND') }
|
|
63
|
+
catch (e) { if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code)) throw e; reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE') }
|
|
64
|
+
return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
|
|
65
|
+
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
66
|
+
capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } }
|
|
67
|
+
}
|
|
68
|
+
async attach(device: string, app: string, pid?: number, timeout = 10000) {
|
|
69
|
+
this.prune()
|
|
70
|
+
const owned = [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete')
|
|
71
|
+
if (this.closing || owned.length >= 4 || owned.some(s => s.device === device)) throw new DebuggerError('DEBUGGER_BUSY')
|
|
72
|
+
const s: Session = { id: randomUUID(), device, target: null, state: 'attaching', stop: null, reason: null,
|
|
73
|
+
queue: Promise.resolve(), events: [], sequence: 0, cleanup: { status: 'pending', remaining_resources: [] }, created: Date.now(), activity: Date.now() }
|
|
74
|
+
this.sessions.set(s.id, s)
|
|
75
|
+
return this.serial(s, async () => {
|
|
76
|
+
const deadline = Date.now() + timeout
|
|
77
|
+
const remaining = () => { debuggerRemaining(timeout); const ms = deadline - Date.now(); if (ms <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT'); return ms }
|
|
78
|
+
try {
|
|
79
|
+
await this.prerequisite(); remaining()
|
|
80
|
+
const candidates = (await this.native.candidates(device, app)).filter(t => pid === undefined || t.pid === pid); remaining()
|
|
81
|
+
if (!candidates.length) throw new DebuggerError('PROCESS_NOT_FOUND')
|
|
82
|
+
if (candidates.length > 1) throw new DebuggerError('PROCESS_AMBIGUOUS')
|
|
83
|
+
s.target = candidates[0]
|
|
84
|
+
s.port = await this.native.forward(device, s.target.pid); remaining()
|
|
85
|
+
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`)
|
|
86
|
+
s.worker = await this.workerFactory(event => this.event(s, event)); remaining()
|
|
87
|
+
s.cleanup.remaining_resources.push('jdi_worker')
|
|
88
|
+
await s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining())
|
|
89
|
+
await this.native.identity(device, s.target); remaining()
|
|
90
|
+
if (s.state === 'attaching') s.state = 'running'
|
|
91
|
+
if (['failed', 'disconnected', 'detached'].includes(s.state)) throw new DebuggerError('DEBUGGER_ATTACH_FAILED')
|
|
92
|
+
s.timer = setInterval(() => {
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
const reason = debuggerExpiry(s.created, s.activity, s.pausedAt, now)
|
|
95
|
+
if (reason) { s.reason = reason; void this.serial(s, () => this.cleanup(s, true)) }
|
|
96
|
+
}, 1000); s.timer.unref()
|
|
97
|
+
} catch (e) {
|
|
98
|
+
s.state = 'failed'; s.reason = e instanceof DebuggerError ? e.code : 'DEBUGGER_ATTACH_FAILED'
|
|
99
|
+
await this.cleanup(s, true)
|
|
100
|
+
return this.result(s, { success: false, error: { code: s.reason, message: s.reason, retryable: false } })
|
|
101
|
+
}
|
|
102
|
+
return this.result(s)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
private async cleanup(s: Session, requestDetach: boolean): Promise<void> {
|
|
106
|
+
if (s.cleaning) return s.cleaning
|
|
107
|
+
s.cleaning = withDebuggerBudget(10000, undefined, async () => {
|
|
108
|
+
if (s.timer) clearInterval(s.timer)
|
|
109
|
+
const terminal = ['failed', 'disconnected'].includes(s.state) ? s.state : 'detached'
|
|
110
|
+
s.state = 'detaching'; s.stop = null; s.pausedAt = undefined
|
|
111
|
+
const remaining: string[] = []
|
|
112
|
+
if (s.worker) {
|
|
113
|
+
if (requestDetach) { try { await s.worker.call('detach', {}, 3000) } catch { /* EOF/transport closure is the fallback; execution remains unknown. */ } }
|
|
114
|
+
try { await s.worker.close(); s.worker = undefined } catch { remaining.push('jdi_worker') }
|
|
115
|
+
}
|
|
116
|
+
if (s.port !== undefined && s.target) {
|
|
117
|
+
try { await this.native.removeForward(s.device, s.port, s.target.pid); s.port = undefined }
|
|
118
|
+
catch { remaining.push(`forward:tcp:${s.port}`) }
|
|
119
|
+
}
|
|
120
|
+
s.events = []; s.cleanup = { status: remaining.length ? 'failed' : 'complete', remaining_resources: remaining }
|
|
121
|
+
s.state = terminal; s.terminalAt = Date.now()
|
|
122
|
+
})
|
|
123
|
+
try { await s.cleaning } finally { s.cleaning = undefined }
|
|
124
|
+
}
|
|
125
|
+
async command(id: string, command: string, args: Record<string, any> = {}, timeout = 10000) {
|
|
126
|
+
const s = this.get(id)
|
|
127
|
+
if (command === 'events') {
|
|
128
|
+
const deadline = Date.now() + timeout
|
|
129
|
+
const after = args.afterSequence ?? 0
|
|
130
|
+
if (after > s.sequence) throw new DebuggerError('INVALID_ARGUMENT')
|
|
131
|
+
if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
132
|
+
if (s.worker && ['running', 'paused'].includes(s.state)) await s.worker.call('heartbeat', {}, Math.min(timeout, 1000))
|
|
133
|
+
while (Date.now() < deadline && !s.events.some(e => e.sequence > after) && s.cleanup.status === 'pending') {
|
|
134
|
+
if (args.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())))
|
|
136
|
+
}
|
|
137
|
+
const events = s.events.filter(e => e.sequence > after).slice(0, 100)
|
|
138
|
+
return this.result(s, { events, next_cursor: events.at(-1)?.sequence ?? s.sequence, overflow: after < (s.events[0]?.sequence ?? s.sequence + 1) - 1, timed_out: events.length === 0 })
|
|
139
|
+
}
|
|
140
|
+
return this.serial(s, async () => {
|
|
141
|
+
if (command === 'detach') { if (s.cleanup.status !== 'complete') await this.cleanup(s, true); return this.result(s, { success: s.cleanup.status === 'complete', execution_state: 'unknown' }) }
|
|
142
|
+
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))) return this.result(s)
|
|
143
|
+
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)) throw new DebuggerError('DEBUGGER_DISCONNECTED')
|
|
144
|
+
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
145
|
+
if (args.stopId !== s.stop?.stop_id) throw new DebuggerError('DEBUGGER_STALE_REFERENCE')
|
|
146
|
+
if (s.state !== 'paused') throw new DebuggerError('DEBUGGER_NOT_PAUSED')
|
|
147
|
+
}
|
|
148
|
+
if (command === 'step' || command === 'resume') { s.state = 'running'; s.stop = null; s.pausedAt = undefined }
|
|
149
|
+
try {
|
|
150
|
+
const data = await s.worker.call(command, args, timeout)
|
|
151
|
+
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data })
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e instanceof DebuggerError && e.code === 'DEBUGGER_DISCONNECTED') {
|
|
154
|
+
s.state = 'disconnected'; s.reason = 'transport_lost'; await this.cleanup(s, false)
|
|
155
|
+
if (command === 'state') return this.result(s)
|
|
156
|
+
}
|
|
157
|
+
if ((command === 'step' || command === 'resume' || command === 'set' || command === 'remove') && e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEBUGGER_WORKER_FAILED'].includes(e.code)) {
|
|
158
|
+
s.state = 'failed'; s.reason = e.code; await this.cleanup(s, true)
|
|
159
|
+
}
|
|
160
|
+
throw e
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)) }
|
|
165
|
+
}
|
|
166
|
+
export const debuggerSessions = new DebuggerSessions()
|
|
167
|
+
|
|
168
|
+
registerDebuggerPauseProvider(device => debuggerSessions.paused(device))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { readFile, mkdtemp, chmod, rm } from 'node:fs/promises'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { detectJavaHome } from '../utils/java.js'
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile)
|
|
12
|
+
const operations = new AsyncLocalStorage<{ deadline: number; signal?: AbortSignal }>()
|
|
13
|
+
export function withDebuggerBudget<T>(timeout: number, signal: AbortSignal | undefined, operation: () => Promise<T>) {
|
|
14
|
+
return operations.run({ deadline: performance.now() + timeout, signal }, operation)
|
|
15
|
+
}
|
|
16
|
+
export function debuggerRemaining(fallback: number) {
|
|
17
|
+
const context = operations.getStore()
|
|
18
|
+
if (context?.signal?.aborted) throw new DebuggerError('DEBUGGER_CANCELLED')
|
|
19
|
+
const remaining = Math.floor((context?.deadline ?? Infinity) - performance.now())
|
|
20
|
+
if (remaining <= 0) throw new DebuggerError('DEBUGGER_TIMEOUT')
|
|
21
|
+
return Math.min(fallback, remaining)
|
|
22
|
+
}
|
|
23
|
+
export function debuggerSignal() { return operations.getStore()?.signal }
|
|
24
|
+
export function nativeError(error: any, fallback: string): DebuggerError {
|
|
25
|
+
if (error instanceof DebuggerError) return error
|
|
26
|
+
if (debuggerSignal()?.aborted) return new DebuggerError('DEBUGGER_CANCELLED')
|
|
27
|
+
if (error?.killed) return new DebuggerError('DEBUGGER_TIMEOUT')
|
|
28
|
+
return new DebuggerError(fallback)
|
|
29
|
+
}
|
|
30
|
+
export class DebuggerError extends Error { constructor(public code: string) { super(code) } }
|
|
31
|
+
export const workerSource = fileURLToPath(new URL('../../worker/DebuggerWorker.java', import.meta.url))
|
|
32
|
+
let compiled: Promise<{ java: string; directory: string }> | undefined
|
|
33
|
+
export async function prepareWorker() {
|
|
34
|
+
compiled ??= (async () => {
|
|
35
|
+
const home = await detectJavaHome({ quiet: true })
|
|
36
|
+
const java = home ? path.join(home, 'bin', 'java') : 'java'
|
|
37
|
+
const javac = home ? path.join(home, 'bin', 'javac') : 'javac'
|
|
38
|
+
const version = await exec(java, ['-version'], { timeout: debuggerRemaining(5000), signal: debuggerSignal() })
|
|
39
|
+
const major = Number(/version "(\d+)/.exec(version.stderr)?.[1])
|
|
40
|
+
if (!(major >= 17)) throw new DebuggerError('DEBUGGER_UNSUPPORTED')
|
|
41
|
+
const source = await readFile(workerSource)
|
|
42
|
+
const key = createHash('sha256').update(source).update(version.stderr).digest('hex').slice(0, 16)
|
|
43
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), `mobile-debug-jdi-${key}-`))
|
|
44
|
+
await chmod(directory, 0o700)
|
|
45
|
+
try { await exec(javac, ['--release', '17', '--add-modules', 'jdk.jdi', '-d', directory, workerSource], { timeout: debuggerRemaining(15000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 }) }
|
|
46
|
+
catch (e) { await rm(directory, { recursive: true, force: true }); throw nativeError(e, 'DEBUGGER_UNSUPPORTED') }
|
|
47
|
+
return { java, directory }
|
|
48
|
+
})().catch(error => { compiled = undefined; throw error })
|
|
49
|
+
return compiled
|
|
50
|
+
}
|
|
51
|
+
export type WorkerEvent = Record<string, any>
|
|
52
|
+
export interface DebugWorker {
|
|
53
|
+
call(command: string, args?: Record<string, unknown>, timeout?: number): Promise<any>
|
|
54
|
+
close(): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
export class JdiWorker implements DebugWorker {
|
|
57
|
+
private child: ChildProcessWithoutNullStreams
|
|
58
|
+
private pending = new Map<number, { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }>()
|
|
59
|
+
private sequence = 0
|
|
60
|
+
private dead = false
|
|
61
|
+
private exited: Promise<void>
|
|
62
|
+
constructor(java: string, directory: string, onEvent: (event: WorkerEvent) => void) {
|
|
63
|
+
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
64
|
+
let buffer = ''
|
|
65
|
+
const fail = () => {
|
|
66
|
+
if (this.dead) return
|
|
67
|
+
this.dead = true
|
|
68
|
+
for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new DebuggerError('DEBUGGER_WORKER_FAILED')) }
|
|
69
|
+
this.pending.clear()
|
|
70
|
+
onEvent({ event: 'worker_failed' })
|
|
71
|
+
}
|
|
72
|
+
this.exited = new Promise(resolve => { this.child.once('exit', () => { fail(); resolve() }); this.child.once('error', () => { fail(); resolve() }) })
|
|
73
|
+
this.child.stdin.on('error', fail)
|
|
74
|
+
this.child.stderr.resume() // Never copy worker data or VM diagnostics into MCP stdout/logs.
|
|
75
|
+
this.child.stdout.setEncoding('utf8')
|
|
76
|
+
this.child.stdout.on('data', (chunk: string) => {
|
|
77
|
+
buffer += chunk
|
|
78
|
+
if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) { fail(); this.child.kill('SIGKILL'); return }
|
|
79
|
+
let end: number
|
|
80
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
81
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
|
|
82
|
+
try {
|
|
83
|
+
if (Buffer.byteLength(line) > 1024 * 1024) throw new Error('IPC_LIMIT')
|
|
84
|
+
const msg = JSON.parse(line)
|
|
85
|
+
if (msg.event) onEvent(msg)
|
|
86
|
+
else {
|
|
87
|
+
const request = this.pending.get(msg.id)
|
|
88
|
+
if (!request) continue
|
|
89
|
+
clearTimeout(request.timer); this.pending.delete(msg.id)
|
|
90
|
+
if (msg.error) request.reject(new DebuggerError(msg.error.code ?? 'DEBUGGER_WORKER_FAILED'))
|
|
91
|
+
else request.resolve(msg.result)
|
|
92
|
+
}
|
|
93
|
+
} catch { fail(); this.child.kill('SIGKILL'); return }
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
call(command: string, args: Record<string, unknown> = {}, timeout = 10000): Promise<any> {
|
|
98
|
+
if (this.dead) return Promise.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'))
|
|
99
|
+
try { timeout = Math.min(timeout, debuggerRemaining(timeout)) } catch (e) { return Promise.reject(e) }
|
|
100
|
+
const id = ++this.sequence
|
|
101
|
+
const message = JSON.stringify({ ...args, command, id }) + '\n'
|
|
102
|
+
if (Buffer.byteLength(message) > 1024 * 1024) return Promise.reject(new DebuggerError('INVALID_ARGUMENT'))
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const signal = debuggerSignal()
|
|
105
|
+
const abort = () => { this.pending.delete(id); clearTimeout(timer); reject(new DebuggerError('DEBUGGER_CANCELLED')) }
|
|
106
|
+
const done = () => signal?.removeEventListener('abort', abort)
|
|
107
|
+
const timer = setTimeout(() => { this.pending.delete(id); done(); reject(new DebuggerError('DEBUGGER_TIMEOUT')) }, timeout)
|
|
108
|
+
this.pending.set(id, { resolve: value => { done(); resolve(value) }, reject: error => { done(); reject(error) }, timer })
|
|
109
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
110
|
+
this.child.stdin.write(message)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
async close() {
|
|
114
|
+
this.child.stdin.end()
|
|
115
|
+
const kill = setTimeout(() => this.child.kill('SIGKILL'), 2000)
|
|
116
|
+
try { await this.exited } finally { clearTimeout(kill) }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function createWorker(onEvent: (event: WorkerEvent) => void) {
|
|
120
|
+
const { java, directory } = await prepareWorker()
|
|
121
|
+
return new JdiWorker(java, directory, onEvent)
|
|
122
|
+
}
|
package/src/interact/android.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evidenceDispatch } from '../observe/evidence.js'
|
|
2
|
+
import { beforeInputDispatch } from './conditional-action.js'
|
|
1
3
|
import { markDispatchStarted } from '../utils/operation-budget.js'
|
|
2
4
|
import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
3
5
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
@@ -13,8 +15,9 @@ export class AndroidInteract {
|
|
|
13
15
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
14
16
|
|
|
15
17
|
try {
|
|
18
|
+
await beforeInputDispatch('android', deviceInfo.id)
|
|
16
19
|
markDispatchStarted()
|
|
17
|
-
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
|
|
20
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs }))
|
|
18
21
|
return { device: deviceInfo, success: true, x, y }
|
|
19
22
|
} catch (e) {
|
|
20
23
|
return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
|
|
@@ -26,8 +29,9 @@ export class AndroidInteract {
|
|
|
26
29
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
27
30
|
|
|
28
31
|
try {
|
|
32
|
+
await beforeInputDispatch('android', deviceInfo.id)
|
|
29
33
|
markDispatchStarted()
|
|
30
|
-
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
|
|
34
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs }))
|
|
31
35
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
|
|
32
36
|
} catch (e) {
|
|
33
37
|
return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
|
|
@@ -42,8 +46,9 @@ export class AndroidInteract {
|
|
|
42
46
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
43
47
|
const encodedText = text.replace(/\s/g, '%s')
|
|
44
48
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
49
|
+
await beforeInputDispatch('android', deviceInfo.id)
|
|
45
50
|
markDispatchStarted()
|
|
46
|
-
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
|
|
51
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs }))
|
|
47
52
|
return { device: deviceInfo, success: true, text }
|
|
48
53
|
} catch (e) {
|
|
49
54
|
return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
|
|
@@ -55,8 +60,9 @@ export class AndroidInteract {
|
|
|
55
60
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
56
61
|
|
|
57
62
|
try {
|
|
63
|
+
await beforeInputDispatch('android', deviceInfo.id)
|
|
58
64
|
markDispatchStarted()
|
|
59
|
-
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
|
|
65
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs }))
|
|
60
66
|
return { device: deviceInfo, success: true }
|
|
61
67
|
} catch (e) {
|
|
62
68
|
return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type { ActionConditionResult } from '../types.js'
|
|
3
|
+
import type { ExpectedState } from '../server/condition-schema.js'
|
|
4
|
+
import { ToolsObserve } from '../observe/index.js'
|
|
5
|
+
import { getSnapshotSessionId, hasSnapshotBaseline } from '../observe/snapshot-metadata.js'
|
|
6
|
+
import { runWithSeparateBudget } from '../utils/operation-budget.js'
|
|
7
|
+
import { ToolsNetwork } from '../network/index.js'
|
|
8
|
+
|
|
9
|
+
export interface ConditionalAction {
|
|
10
|
+
platform: 'android' | 'ios'
|
|
11
|
+
deviceId: string
|
|
12
|
+
expected: ExpectedState
|
|
13
|
+
result: ActionConditionResult
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const context = new AsyncLocalStorage<ConditionalAction>()
|
|
17
|
+
|
|
18
|
+
export function createConditionalAction(platform: 'android' | 'ios', deviceId: string, expected: ExpectedState): ConditionalAction {
|
|
19
|
+
return { platform, deviceId, expected, result: {
|
|
20
|
+
status: 'not_checked',
|
|
21
|
+
expected: { snapshot_session_id: expected.snapshotSessionId, snapshot_revision: expected.snapshotRevision },
|
|
22
|
+
observed: null, elapsed_ms: 0, reason: 'ACTION_NOT_REACHED'
|
|
23
|
+
} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function withConditionalAction<T>(condition: ConditionalAction, operation: () => Promise<T>) {
|
|
27
|
+
return context.run(condition, operation)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isConditionBlocked() {
|
|
31
|
+
const status = context.getStore()?.result.status
|
|
32
|
+
return status === 'mismatch' || status === 'unavailable'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Guarded calls reset the observation window only when input is about to spawn.
|
|
36
|
+
export function notifyActionStart() {
|
|
37
|
+
if (!context.getStore()) ToolsNetwork.notifyActionStart()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function conditionFailureCode(condition: ConditionalAction) {
|
|
41
|
+
return condition.result.status === 'mismatch' ? 'PRECONDITION_FAILED' as const : 'PRECONDITION_UNAVAILABLE' as const
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function beforeInputDispatch(platform: 'android' | 'ios', deviceId: string) {
|
|
45
|
+
const condition = context.getStore()
|
|
46
|
+
if (!condition) return
|
|
47
|
+
const started = performance.now()
|
|
48
|
+
const result = condition.result
|
|
49
|
+
result.status = 'unavailable'
|
|
50
|
+
delete result.reason
|
|
51
|
+
try {
|
|
52
|
+
if (platform !== condition.platform || deviceId !== condition.deviceId) result.reason = 'DEVICE_MISMATCH'
|
|
53
|
+
else if (condition.expected.snapshotSessionId !== getSnapshotSessionId()) result.reason = 'SESSION_MISMATCH'
|
|
54
|
+
else if (!hasSnapshotBaseline(`${platform}:${deviceId}`, condition.expected.snapshotRevision)) result.reason = 'BASELINE_UNAVAILABLE'
|
|
55
|
+
else {
|
|
56
|
+
const tree = await runWithSeparateBudget(condition.expected.timeoutMs ?? 15000, () =>
|
|
57
|
+
ToolsObserve.getUITreeHandler({ platform, deviceId })) as any
|
|
58
|
+
if (tree?.error) result.reason = 'OBSERVATION_FAILED'
|
|
59
|
+
else if (!tree || !Array.isArray(tree.elements) || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision < 1 ||
|
|
60
|
+
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms <= 0 ||
|
|
61
|
+
!tree.resolution || !Number.isFinite(tree.resolution.width) || !Number.isFinite(tree.resolution.height) ||
|
|
62
|
+
!(tree.resolution.width > 0) || !(tree.resolution.height > 0)) result.reason = 'OBSERVATION_INVALID'
|
|
63
|
+
else if (tree.device?.platform !== platform || tree.device?.id !== deviceId) result.reason = 'DEVICE_MISMATCH'
|
|
64
|
+
else {
|
|
65
|
+
result.observed = tree
|
|
66
|
+
result.status = tree.snapshot_revision === condition.expected.snapshotRevision ? 'matched' : 'mismatch'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
result.reason = /TIMEOUT|timed out/i.test(String(error)) ? 'CONDITION_TIMEOUT' : 'OBSERVATION_FAILED'
|
|
71
|
+
}
|
|
72
|
+
result.elapsed_ms = performance.now() - started
|
|
73
|
+
if (result.status !== 'matched') throw new Error(conditionFailureCode(condition))
|
|
74
|
+
ToolsNetwork.notifyActionStart()
|
|
75
|
+
}
|