diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000000..6974cdf24c --- /dev/null +++ b/.cursorignore @@ -0,0 +1,55 @@ +# 工作手机 · Cursor 忽略(防持续上传/索引) +# 根因:反编译产物 ~45 万文件,Agent 会反复同步到云端 + +# APK 反编译与资料(最大头) +资料/** +**/apk_analysis/** + +# 微信反编译 / jadx / smali 产物 +sdk/tmp/** +sdk/scripts/gadget_work/** +**/jadx_out/** +**/wechat_decompiled/** +**/smali/** +**/smali_classes*/** + +# 依赖与构建 +**/node_modules/** +**/.pnpm-store/** +**/.yarn/** +**/.npm/** +**/.venv/** +**/venv/** +**/__pycache__/** +**/.pytest_cache/** +**/dist/** +**/build/** +**/.next/** +**/.turbo/** +**/target/** +**/.gradle/** +**/.m2/** +**/vendor/** +**/coverage/** + +# 缓存与索引 +**/.cache/** +**/.codegraph/** +**/.cursor/** +**/.git/objects/** + +# 大文件 +**/*.apk +**/*.aab +**/*.dex +**/*.so +**/*.jar +**/*.zip +**/*.7z +**/*.dmg +**/*.mp4 +**/*.mov +**/*.MP4 +**/*.MOV +**/*.log +**/logs/** diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 0000000000..8bda13d7f7 --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,8 @@ +# 仅排除索引搜索,Agent 仍可 @ 读取(工作手机反编译目录) +资料/** +sdk/tmp/** +sdk/scripts/gadget_work/** +**/jadx_out/** +**/wechat_decompiled/** +**/smali/** +**/smali_classes*/** diff --git a/.gitignore b/.gitignore index d39da9a26d..ce24c6147a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,20 @@ sdk/logs/ sdk/tmp_rom/ sdk/.runtime/ +# 本机/真机运行态配置与验收日志(模板文件可提交) +sdk/config/*.env +sdk/config/agent_config_*.json +sdk/agent/config.nas-active.json +sdk/data/operation_logs/ +sdk/app/data/hook/events.jsonl + +# 本地发行包、逆向工作区与设备二进制 +sdk/releases/ +sdk/scripts/gadget_work/ +sdk/scripts/frida-server-* +sdk/scripts/frida-gadget-* +sdk/scripts/wechat_original.apk + # Obsidian 粘贴图误落 vault 根(应进 开发文档/**/images/) /Pasted image*.png /Pasted image*.jpg diff --git a/sdk/.dockerignore b/sdk/.dockerignore new file mode 100644 index 0000000000..b57887e944 --- /dev/null +++ b/sdk/.dockerignore @@ -0,0 +1,20 @@ +.git +.env +.env.* +*.log +__pycache__/ +*.pyc +.pytest_cache/ +.gradle/ +node_modules/ +android-app/ +admin/ +releases/ +tests/ +data/ +app/data/cunke_bao_config.json +app/data/hook/events.jsonl +app/data/operation_logs/ +app/agent/config.json +agent/config.json +agent/config.*.json diff --git a/sdk/Dockerfile b/sdk/Dockerfile index 09da03e1f6..a5fc061e79 100644 --- a/sdk/Dockerfile +++ b/sdk/Dockerfile @@ -1,9 +1,11 @@ # 工作手机SDK v3.1 - Dockerfile(含 AI Brain + Hook 模块管理) -FROM python:3.11-slim +ARG PYTHON_IMAGE=docker.1ms.run/library/python:3.11-slim +FROM ${PYTHON_IMAGE} WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN sed -i 's#deb.debian.org#mirrors.cloud.tencent.com#g; s#security.debian.org#mirrors.cloud.tencent.com#g' /etc/apt/sources.list.d/debian.sources \ + && apt-get update && apt-get install -y --no-install-recommends \ curl \ adb \ && rm -rf /var/lib/apt/lists/* diff --git a/sdk/agent/config.nas-primary.example.json b/sdk/agent/config.nas-primary.example.json new file mode 100644 index 0000000000..36d72c679a --- /dev/null +++ b/sdk/agent/config.nas-primary.example.json @@ -0,0 +1,12 @@ +{ + "device_id": "xgfe65eimrrofyws", + "server_url": "ws://192.168.110.101:8899/ws/device", + "public_servers": [ + "ws://192.168.1.201:8899/ws/device", + "ws://192.168.110.167:8899/ws/device", + "ws://open.quwanzhi.com:8899/ws/device" + ], + "heartbeat_interval": 10, + "project_id": "cunkebao", + "comment": "顺序固定: ①NAS局域网(server_url) ②本机Docker(局网IP,示例110.167) ③NAS外网frp。勿把frp排在Docker前。" +} diff --git a/sdk/agent/sdk_discovery.py b/sdk/agent/sdk_discovery.py new file mode 100644 index 0000000000..5692510483 --- /dev/null +++ b/sdk/agent/sdk_discovery.py @@ -0,0 +1,59 @@ +""" +设备端 SDK 自动发现 — 监听 UDP beacon(8898),无线主控免手填 IP。 + +与 sdk/app/services/discovery_service.py BEACON 协议对齐。 +""" + +from __future__ import annotations + +import json +import socket +import time +from typing import Optional + +BEACON_PORT = 8898 +BEACON_MAGIC = "WORKPHONE_SDK" + + +def _pick_best_ip(ips: list) -> str: + """优先 192.168.110.x(工作机常用网段),否则取第一个私网 IP。""" + for ip in ips: + if ip.startswith("192.168.110."): + return ip + for ip in ips: + if ip.startswith(("192.168.", "10.", "172.")): + return ip + return ips[0] + + +def discover_sdk_ws_base(timeout: float = 20.0) -> Optional[str]: + """ + 监听局域网 UDP beacon,返回 WebSocket 基础地址。 + 例: ws://192.168.110.251:8899/ws/device + """ + deadline = time.time() + timeout + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("", BEACON_PORT)) + except OSError: + # Termux 可能无权限绑 8898,改绑任意端口并只 recv + sock.bind(("", 0)) + sock.settimeout(1.0) + + while time.time() < deadline: + try: + data, _addr = sock.recvfrom(4096) + payload = json.loads(data.decode("utf-8")) + if payload.get("magic") != BEACON_MAGIC: + continue + port = int(payload.get("port") or 8899) + ws_path = (payload.get("ws_path") or "/ws/device").rstrip("/") + ips = payload.get("ips") or [] + host = _pick_best_ip(ips) if ips else "127.0.0.1" + return f"ws://{host}:{port}{ws_path}" + except socket.timeout: + continue + except Exception: + continue + return None diff --git a/sdk/android-app/app/build.gradle b/sdk/android-app/app/build.gradle index ed7cf78ff0..2fd7f6fd04 100644 --- a/sdk/android-app/app/build.gradle +++ b/sdk/android-app/app/build.gradle @@ -14,7 +14,7 @@ android { versionCode 6 versionName "5.0.1" buildConfigField "String", "PWA_URL", "\"https://ckbapi.quwanzhi.com\"" - buildConfigField "String", "DEFAULT_WS", "\"wss://workphone.quwanzhi.com/ws/device\"" + buildConfigField "String", "DEFAULT_WS", "\"wss://wpsdk.quwanzhi.com/ws/device\"" buildConfigField "String", "AI_API_URL", "\"https://ckbapi.quwanzhi.com\"" } diff --git a/sdk/android-app/app/src/main/AndroidManifest.xml b/sdk/android-app/app/src/main/AndroidManifest.xml index a3b0933bf4..5a1d3f8a77 100644 --- a/sdk/android-app/app/src/main/AndroidManifest.xml +++ b/sdk/android-app/app/src/main/AndroidManifest.xml @@ -19,6 +19,10 @@ + + + android:exported="true"> diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/App.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/App.kt index bd23884abe..e4b5a985bd 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/App.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/App.kt @@ -1,6 +1,7 @@ package com.system.cloudservice import android.app.Application +import com.system.cloudservice.service.AgentAccessibilityService import com.system.cloudservice.util.Logger class App : Application() { @@ -13,6 +14,7 @@ class App : Application() { super.onCreate() instance = this Logger.init(this) - Logger.i("AI数智员工 Application created (${BuildConfig.VERSION_NAME})") + AgentAccessibilityService.enableIfAuthorized(this) + Logger.i("工作手机 Application created (${BuildConfig.VERSION_NAME})") } } diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/AgentEngine.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/AgentEngine.kt index 78a7858063..4d2bad349e 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/AgentEngine.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/AgentEngine.kt @@ -35,6 +35,7 @@ class AgentEngine( private var deviceId = "" private var projectId = "" private var serverUrl = "" + private var pairingToken = "" private var heartbeatIntervalMs = 30_000L private var lastPongAt = 0L private var commandsExecuted = 0 @@ -58,6 +59,9 @@ class AgentEngine( fun isFridaReady(): Boolean = fridaBridge?.isReady == true + /** 微信进程真实注入状态;与 frida-server 端口就绪严格分离。 */ + fun isWechatHookAttached(): Boolean = fridaBridge?.isTargetHooked() == true + /** AB-02~06 runtime 信号:risk_sentinel / nurture / device_guard / sensor / touch */ fun getAntiBanStatus(): Map = antiBan.statusSummary() @@ -87,6 +91,8 @@ class AgentEngine( ) { this.deviceId = deviceId this.projectId = projectId + this.pairingToken = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE) + .getString("pairing_token", "")?.trim().orEmpty() this.publicServers = publicServers.filter { it.isNotBlank() } this.serverUrl = buildWsUrl(serverUrl, deviceId) this.candidates = buildCandidates(this.serverUrl, this.publicServers, deviceId) @@ -114,12 +120,19 @@ class AgentEngine( } private fun buildWsUrl(base: String, deviceId: String): String { - val cleaned = base.trimEnd('/') - return if (cleaned.contains("/ws/device")) { + val parts = base.trim().split("?", limit = 2) + val cleaned = parts[0].trimEnd('/') + val path = if (cleaned.contains("/ws/device")) { if (cleaned.endsWith(deviceId)) cleaned else "$cleaned/$deviceId" } else { "$cleaned/ws/device/$deviceId" } + val queryParts = mutableListOf() + if (parts.size == 2 && parts[1].isNotBlank()) queryParts.add(parts[1]) + if (pairingToken.isNotBlank() && queryParts.none { it.startsWith("token=") }) { + queryParts.add("token=${java.net.URLEncoder.encode(pairingToken, "UTF-8")}") + } + return if (queryParts.isEmpty()) path else "$path?${queryParts.joinToString("&")}" } /** BIND-03:构建有序候选 [主连接, 公网1, ...],自动补全 + 去重。 */ @@ -230,7 +243,8 @@ class AgentEngine( "device_profile" to info, "capabilities" to buildCapabilities(), "connect_stage" to connectStage, // BIND-07 寻服阶段上报 - "server_url" to serverUrl, + // 配对令牌仅用于握手,禁止上报到设备资料或管理 API。 + "server_url" to serverUrl.substringBefore('?'), "server_candidate_count" to candidates.size, ) wsManager.send(gson.toJson(msg)) diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/SkillExecutor.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/SkillExecutor.kt index 3006adc205..057b7b2772 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/SkillExecutor.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/SkillExecutor.kt @@ -3,12 +3,14 @@ package com.system.cloudservice.engine import android.content.Context import android.content.Intent import android.os.Build +import android.util.Base64 import com.system.cloudservice.antiban.AntiBanManager import com.system.cloudservice.frida.FridaBridge import com.system.cloudservice.service.AgentAccessibilityService import com.system.cloudservice.util.DeviceInfo import com.system.cloudservice.util.Logger import com.system.cloudservice.util.ShellExecutor +import java.io.File class SkillExecutor( private val context: Context, @@ -26,20 +28,25 @@ class SkillExecutor( return mapOf("code" to 429, "message" to "风控暂停中", "data" to status) } - // If a script is specified, route through Frida-priority chain - if (script != null) { + // wechat/douyin 等业务脚本走 Hook 优先链;android/system/device 是设备控制域, + // 不能误当成 Frida 脚本,否则公网 Fleet 的 app_start/ui_tree 会落到 Shell不支持。 + val deviceControlScripts = setOf("android", "system", "device") + if (script != null && script.lowercase() !in deviceControlScripts) { return executeWithFridaPriority(script, action, params) } return try { when (action) { - "open_app" -> openApp(params["package"] as? String ?: "") + "open_app", "app_start", "start_app" -> openApp(params["package"] as? String ?: "") + "app_stop", "stop_app" -> doShell( + mapOf("command" to "am force-stop ${params["package"] ?: ""}") + ) "get_installed_apps" -> getInstalledApps() "get_device_info" -> mapOf("code" to 200, "data" to DeviceInfo.collectFull(context)) - "click" -> doClick(params) + "click", "tap" -> doClick(params) "swipe" -> doSwipe(params) - "input_text" -> doInputText(params) - "key_event" -> doKeyEvent(params) + "input_text", "input" -> doInputText(params) + "key_event", "press_key" -> doKeyEvent(params) "screenshot" -> doScreenshot(params) "back" -> doGlobalAction("back") "home" -> doGlobalAction("home") @@ -139,6 +146,22 @@ class SkillExecutor( if (script != "wechat") return null when (action) { + "ping" -> { + val ver = WechatNoRootAutomation.getWechatVersion(context) + val quick = DeviceInfo.collectQuick(context) + return mapOf( + "code" to 200, + "message" to "pong", + "data" to mapOf( + "success" to true, + "installed" to (ver["success"] == true), + "wechat_version" to ver["version"], + "wechat_running" to (quick["wechat_running"] == true), + "channel" to "no_root_pkg", + ), + "channel" to "no_root_pkg", + ) + } "get_wechat_version" -> { val data = WechatNoRootAutomation.getWechatVersion(context) return mapOf( @@ -330,9 +353,27 @@ class SkillExecutor( } private fun doScreenshot(params: Map): Map { - val path = params["path"] as? String ?: "/sdcard/screenshot.png" - val r = ShellExecutor.execute("screencap -p $path") - return mapOf("code" to if (r.success) 200 else 500, "message" to r.output) + val path = params["path"] as? String + ?: File(context.cacheDir, "workphone_screenshot.png").absolutePath + val r = ShellExecutor.execute("screencap -p '$path' && chmod 644 '$path'") + if (!r.success) { + return mapOf("code" to 500, "message" to r.output) + } + return try { + val bytes = File(path).readBytes() + mapOf( + "code" to 200, + "message" to "ok", + "data" to mapOf( + "image_base64" to Base64.encodeToString(bytes, Base64.NO_WRAP), + "mime_type" to "image/png", + "size" to bytes.size, + "path" to path, + ), + ) + } catch (e: Exception) { + mapOf("code" to 500, "message" to "截图读取失败: ${e.message}") + } } private fun doGlobalAction(action: String): Map { diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/WebSocketManager.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/WebSocketManager.kt index 40001b6e60..c2da303978 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/WebSocketManager.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/engine/WebSocketManager.kt @@ -64,7 +64,7 @@ class WebSocketManager( private fun doConnect() { if (_state.value == ConnectionState.CONNECTING) return _state.value = ConnectionState.CONNECTING - Logger.d("WS connecting: $serverUrl") + Logger.d("WS connecting: ${serverUrl.substringBefore('?')}") val request = Request.Builder().url(serverUrl).build() ws = client.newWebSocket(request, object : WebSocketListener() { diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/frida/FridaBridge.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/frida/FridaBridge.kt index 6909d8cdc8..5ebfac9ba5 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/frida/FridaBridge.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/frida/FridaBridge.kt @@ -65,10 +65,15 @@ class FridaBridge( fun getStatus(): Map = mapOf( "ready" to isReady, + // 当前 APK 只确认 frida-server 端口可达,尚未持有微信 Session/Script。 + // 必须保持 false,避免 UI 与心跳把“服务就绪”误报成“微信已注入”。 + "hooked" to false, "server_running" to serverManager.isRunning, "root_available" to serverManager.hasRoot(), ) + fun isTargetHooked(): Boolean = false + private fun loadDefaultScripts() { try { val assets = context.assets.list("") ?: return diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentAccessibilityService.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentAccessibilityService.kt index eb32b39eca..517e8d21de 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentAccessibilityService.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentAccessibilityService.kt @@ -39,6 +39,40 @@ class AgentAccessibilityService : AccessibilityService() { return flat.split(':').mapNotNull { ComponentName.unflattenFromString(it) } .any { it == expected } } + + /** + * 企业授权设备可由 ADB 一次性授予 WRITE_SECURE_SETTINGS,之后冷启动自动补齐 + * 本应用无障碍服务。保留系统中已经启用的其他服务,不覆盖用户配置。 + */ + fun enableIfAuthorized(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + context.checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) return false + + return try { + val resolver = context.contentResolver + val expected = ComponentName(context, AgentAccessibilityService::class.java) + .flattenToString() + val current = Settings.Secure.getString( + resolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ).orEmpty().split(':').filter { it.isNotBlank() }.toMutableList() + if (expected !in current) current += expected + Settings.Secure.putString( + resolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + current.distinct().joinToString(":"), + ) && Settings.Secure.putInt( + resolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 1, + ) + } catch (e: SecurityException) { + Log.w(TAG, "WRITE_SECURE_SETTINGS 未授权,保留手动授权流程", e) + false + } + } } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentForegroundService.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentForegroundService.kt index ed9e4489e7..d192f77236 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentForegroundService.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/service/AgentForegroundService.kt @@ -46,6 +46,7 @@ class AgentForegroundService : Service() { } private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var engineStartJob: Job? = null var engine: AgentEngine? = null private set @@ -98,7 +99,14 @@ class AgentForegroundService : Service() { isRunning = true startForeground(NOTIFICATION_ID, createNotification("正在连接...")) - initAndStartEngine(serverUrl, projectId, deviceId) + synchronized(this) { + if (engine != null || engineStartJob?.isActive == true) { + Logger.i("AgentEngine 已存在或正在启动,忽略重复 START,仅触发重连") + engine?.onNetworkAvailable() + } else { + initAndStartEngine(serverUrl, projectId, deviceId) + } + } } ACTION_STOP -> { engine?.stop() @@ -110,7 +118,7 @@ class AgentForegroundService : Service() { } private fun initAndStartEngine(serverUrlIn: String, projectId: String, deviceId: String) { - serviceScope.launch { + engineStartJob = serviceScope.launch { // BIND-05:未绑定 server_url 时,先走 UDP beacon 自动发现(同网零配置) var serverUrl = serverUrlIn if (serverUrl.isBlank()) { @@ -215,7 +223,7 @@ class AgentForegroundService : Service() { PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("AI数智员工") + .setContentTitle("工作手机") .setContentText(sanitize(status)) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentIntent(pi) diff --git a/sdk/android-app/app/src/main/java/com/system/cloudservice/ui/DashboardFragment.kt b/sdk/android-app/app/src/main/java/com/system/cloudservice/ui/DashboardFragment.kt index 46bedfa25e..a83fc51981 100644 --- a/sdk/android-app/app/src/main/java/com/system/cloudservice/ui/DashboardFragment.kt +++ b/sdk/android-app/app/src/main/java/com/system/cloudservice/ui/DashboardFragment.kt @@ -34,9 +34,8 @@ class DashboardFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val deviceId = DeviceInfo.getOrCreateDeviceId(requireContext()) - view.findViewById(R.id.tvDeviceIdHeader).text = deviceId - view.findViewById(R.id.tvDeviceIdMd5).text = "MD5 ${DeviceInfo.deviceIdMd5(requireContext())}" + view.findViewById(R.id.tvDeviceIdHeader).text = "设备 ID · MD5" + view.findViewById(R.id.tvDeviceIdMd5).text = DeviceInfo.deviceIdMd5(requireContext()) view.findViewById(R.id.btnScanQr).setOnClickListener { startQrScan() } view.findViewById