feat: implement robust multi-tier text input architecture and ADB diagnostics

- Android Text Input & IME Normalization:
  - Implement progressive 3-tier input architecture in AndroidAdbDriver and AndroidDeviceController:
    - Tier 1 (Primary): Zero-dependency clipboard injection + KEYCODE_PASTE (immune to IME Pinyin interception, full Unicode/Emoji support, preserves multiline formatting).
    - Tier 2 (Broadcast): Base64 broadcast injection when ADBKeyboard is active.
    - Tier 3 (Fallback): Native ADB physical keyevent fallback.
  - Add literal newline normalization (\r\n, \n, \r) to prevent JSON escape string corruption (e.g. '10086\n' -> '10086、n').
  - Add set_clipboard method to UIAutomatorClient without auxiliary APK requirements.

- Diagnostics & System Probes:
  - Add adb_keys diagnostics probe for ADB key auth checks.
  - Integrate diagnostics into CLI doctor command.

- Admin Console & Showcase UI:
  - Improve model service and system router in admin console.
  - Enhance workspace stream view and session state in showcase UI.
This commit is contained in:
somew1nd committed 2026-08-24 16:48:45 -07:00
1 parent 42b8988661
commit ea3708a98f
19 files changed
+785 -89

No files matched your search

+3 -3
View File
@@ -241,10 +241,10 @@
<div style="margin-bottom: 20px;">
<textarea id="task-goal" style="width: 100%; height: 60px; border-radius: 4px; border: 1px solid #e2e8f0; padding: 8px; box-sizing: border-box; font-family: inherit;" placeholder="Enter task goal..."></textarea>
<div style="margin-top: 8px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; color: #475569;">
<label for="task-profile-select" style="font-weight: 600;">Model Profile:</label>
<label for="task-profile-select" style="font-weight: 600;">Agent Architecture:</label>
<select id="task-profile-select" style="padding: 4px 8px; border-radius: 4px; border: 1px solid #cbd5e0; font-size: 0.85rem; background-color: #f8fafc; color: #334155; outline: none; cursor: pointer; flex: 1; margin-left: 10px;">
<option value="flash">⚡ Flash (Fast Response)</option>
<option value="pro">🚀 Pro (Deep Planning)</option>
<option value="flash">⚡ Flash (Reactive Fast Loop)</option>
<option value="pro">🚀 Pro (Multi-Agent Cognitive Graph)</option>
</select>
</div>
<div style="display: flex; gap: 10px; margin-top: 5px;">
+11
View File
@@ -63,6 +63,17 @@ async def restart_adb_server():
}
@router.post("/adb/heal-keys")
async def heal_adb_keys():
"""Auto-heal corrupted ADB authentication RSA keys and return updated readiness."""
heal_result = await readiness_engine.heal_adb_keys()
updated_report = await readiness_engine.run_all()
return {
"heal_result": heal_result,
"report": updated_report,
}
class ConnectAdbRequest(BaseModel):
"""Payload to connect to an Android device over Wi-Fi."""
+27 -37
View File
@@ -17,26 +17,12 @@ from typing import Any
class ModelService:
"""Service handling model profile detection and metadata formatting."""
"""Service handling agent architecture profile detection and metadata formatting."""
@staticmethod
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
if profile:
p_lower = str(profile).lower()
if "pro" in p_lower:
return {
"name": "Pro",
"id": "gemini-3.7-pro",
"provider": "google",
}
elif "flash" in p_lower:
return {
"name": "Flash",
"id": "gemini-3.7-flash",
"provider": "google",
}
model_name = "Flash"
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config
model_id = "gemini-3.7-flash"
provider = "google"
try:
@@ -44,18 +30,31 @@ class ModelService:
llm_cfg = parse_llm_config()
if llm_cfg and llm_cfg.operator:
op_model = str(llm_cfg.operator.model).lower()
provider = str(llm_cfg.operator.provider)
model_id = llm_cfg.operator.model
if "pro" in op_model:
model_name = "Pro"
elif "flash" in op_model:
model_name = "Flash"
else:
model_name = llm_cfg.operator.model.capitalize()
model_id = str(llm_cfg.operator.model)
elif llm_cfg and llm_cfg.default:
provider = str(llm_cfg.default.provider)
model_id = str(llm_cfg.default.model)
except Exception:
pass
return {"name": model_name, "id": model_id, "provider": provider}
# 2. Determine agent architecture name (Flash vs Pro)
arch_name = "Flash"
if profile:
p_lower = str(profile).lower()
if "pro" in p_lower:
arch_name = "Pro"
elif "flash" in p_lower:
arch_name = "Flash"
else:
arch_name = str(profile).capitalize()
return {
"name": arch_name,
"id": model_id,
"provider": provider,
"architecture": f"ARTEMIS {arch_name}",
}
@staticmethod
def resolve_session_profile(
@@ -64,6 +63,7 @@ class ModelService:
running_profile: str | None = None,
agent_names: list[str] | None = None,
) -> str | None:
"""Resolve the Artemis agent architecture profile ('flash' or 'pro') for a session."""
sess_profile = None
d_info_raw = row_dict.get("device_info")
if d_info_raw:
@@ -98,7 +98,7 @@ class ModelService:
if any("flashrunner" in name for name in agent_names_lower):
return "flash"
# Check LLM traces for agent name or model overrides
# Check LLM traces for agent name
if llm_trace_payloads:
for tr_payload in llm_trace_payloads:
if tr_payload:
@@ -115,16 +115,6 @@ class ModelService:
for x in ("planner", "validator", "operator", "checker")
):
return "pro"
m_str = str(p_obj.get("model") or p_obj.get("model_name") or "").lower()
if (
"gemini-3.7-pro" in m_str
or "gemini-3.6-pro" in m_str
or "gemini-1.5-pro" in m_str
or "gpt-4" in m_str
or "claude" in m_str
):
return "pro"
except Exception:
pass
@@ -244,15 +244,15 @@
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
<div class="stream-floating-toolbar" *ngIf="consolidatedBlocks().length > 0 || showPlanningLoader()">
<!-- Active Model Info Chip (Informational Metadata, Not a Button) -->
<!-- Active Architecture Info Chip (Informational Metadata, Not a Button) -->
<div
class="model-info-chip"
[ngClass]="getModelClass(model.name)"
*ngIf="agentService.activeModel() as model"
[title]="'Active Model: ' + model.id + ' (' + model.provider + ')'"
[title]="getArchitectureTooltip(model)"
>
<span class="material-symbols-outlined model-icon">{{ getModelIcon(model.name) }}</span>
<span class="model-label">Model:</span>
<span class="model-label">Architecture:</span>
<span class="model-name">{{ getModelDisplayName(model.name) }}</span>
</div>
@@ -20,7 +20,7 @@ import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { Session, ModelInfo } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
@@ -1262,6 +1262,19 @@ export class AgentStreamComponent implements AfterViewInit {
return '';
}
public getArchitectureTooltip(model?: ModelInfo | null): string {
if (!model) return 'Agent Architecture: ARTEMIS Flash (Reactive Fast Loop)';
const name = this.getModelDisplayName(model.name);
const isPro = name.toLowerCase().includes('pro');
const archDesc = isPro
? 'ARTEMIS Pro (Multi-Agent Cognitive State Graph)'
: 'ARTEMIS Flash (Reactive Fast Loop)';
if (model.id) {
return `Agent Architecture: ${archDesc} · LLM: ${model.id} (${model.provider || 'google'})`;
}
return `Agent Architecture: ${archDesc}`;
}
public formatTokenCount(tokens?: number): string {
return formatTokenCount(tokens);
}
@@ -18,6 +18,7 @@ export interface ModelInfo {
name: string;
id: string;
provider: string;
architecture?: string;
}
export interface TaskQueueItem {
@@ -1576,13 +1576,14 @@
<!-- Composer Bottom Action Bar -->
<div class="composer-action-bar">
<div class="bar-left">
<!-- Frosted Glass Model Toggle Capsule -->
<!-- Frosted Glass Architecture Toggle Capsule -->
<div class="model-toggle-capsule">
<button
type="button"
class="model-toggle-btn btn-flash"
[class.active]="selectedProfile() === 'flash'"
(click)="setProfile('flash')"
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)"
>
<span class="material-symbols-outlined btn-icon">bolt</span>
<span class="btn-text">Flash</span>
@@ -1592,6 +1593,7 @@
class="model-toggle-btn btn-pro"
[class.active]="selectedProfile() === 'pro'"
(click)="setProfile('pro')"
title="Pro: Multi-agent graph architecture with planner & closed-loop verification for complex tasks"
>
<span class="material-symbols-outlined btn-icon">psychology</span>
<span class="btn-text">Pro</span>
@@ -1745,7 +1747,8 @@
<span class="app-names-label">{{ getAppNamesDisplay(item.apps) }}</span>
</div>
<span class="mode-badge" [class.mode-pro]="item.profile === 'pro'" [class.mode-flash]="item.profile === 'flash'">
<span class="mode-badge" [class.mode-pro]="item.profile === 'pro'" [class.mode-flash]="item.profile === 'flash'"
[title]="item.profile === 'pro' ? 'Uses ARTEMIS Pro (Multi-Agent Cognitive State Graph)' : 'Uses ARTEMIS Flash (Reactive Fast Loop)'">
{{ item.profile === 'pro' ? 'Pro' : 'Flash' }}
</span>
</div>
@@ -103,8 +103,8 @@
rows="1"
[disabled]="isSubmitting"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Best for complex long-running tasks, deep reasoning & video analysis'
: 'Flash: Fast execution for direct actions and tasks within 25 steps'"
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
class="dock-textarea"
spellcheck="false"
></textarea>
@@ -141,17 +141,17 @@
<!-- Bottom Segmented Controls Row -->
<div class="card-footer-row">
<div class="footer-left-group">
<!-- Segmented Model Toggle (Flash / Pro) -->
<div class="model-profile-toggle" role="group" aria-label="Select AI Model">
<!-- Segmented Architecture Toggle (Flash / Pro) -->
<div class="model-profile-toggle" role="group" aria-label="Select Agent Architecture">
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
title="Flash: Fast execution for standard app workflows">
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)">
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
<span class="pill-label">Flash</span>
</button>
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
title="Pro: Deep multimodal reasoning for complex multi-step tasks">
title="Pro: Multi-agent cognitive graph architecture with planner & closed-loop verification for complex tasks">
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
<span class="pill-label">Pro</span>
</button>
@@ -64,7 +64,7 @@ export class WorkspaceComponent implements OnInit {
}
/**
* Set model profile ('flash' vs 'pro')
* Set agent architecture profile ('flash' vs 'pro')
*/
public setProfile(profile: 'flash' | 'pro', event?: MouseEvent): void {
if (event) {
+17
View File
@@ -341,6 +341,23 @@ class UIAutomatorClient:
logger.error(f"UIAutomator2 clear_text failed: {e}")
return False
def set_clipboard(self, text: str) -> bool:
"""Set text to system clipboard via UIAutomator2.
Args:
text: The string to set in the device clipboard.
Returns:
True if successful, False otherwise.
"""
try:
device = self._ensure_connected()
device.set_clipboard(text)
return True
except Exception as e:
logger.debug(f"UIAutomator2 set_clipboard failed: {e}")
return False
def get_hierarchy(self) -> str:
"""Get the UI hierarchy XML from the device.
+34 -10
View File
@@ -156,22 +156,46 @@ class AndroidDeviceController(MobileDeviceController):
async def input_text(self, text: str) -> bool:
try:
# Fast path for ASCII text: Use ADB directly.
# This avoids toggling FastInputIME, which hides the keyboard and can cause focus loss.
is_ascii = all(ord(c) < 128 for c in text)
if is_ascii:
return self._input_text_adb_fallback(text)
norm_text = (
text.replace(r"\r\n", "\n")
.replace(r"\n", "\n")
.replace(r"\r", "\n")
)
self.ui_adb_client.send_text(text)
return True
# 1. Tier 1: Try clipboard injection + KEYCODE_PASTE (Zero IME interference, preserves multiline, works for all charsets)
if self.ui_adb_client:
try:
set_clip_ok = False
if hasattr(self.ui_adb_client, "set_clipboard"):
set_clip_ok = self.ui_adb_client.set_clipboard(norm_text)
elif hasattr(self.ui_adb_client, "_device") and self.ui_adb_client._device:
self.ui_adb_client._device.set_clipboard(norm_text)
set_clip_ok = True
elif hasattr(self.ui_adb_client, "_ensure_connected"):
dev = self.ui_adb_client._ensure_connected()
dev.set_clipboard(norm_text)
set_clip_ok = True
if set_clip_ok:
self.device.shell("input keyevent 279")
return True
except Exception as e:
logger.debug(f"Clipboard paste fallback: {e}")
return self._input_text_adb_fallback(norm_text)
except Exception as e:
logger.warning(f"UIAutomator2 send_text failed: {e}, falling back to ADB shell")
return self._input_text_adb_fallback(text)
logger.error(f"Failed to input text: {e}")
return False
def _input_text_adb_fallback(self, text: str) -> bool:
"""Fallback method using ADB shell input text command."""
try:
lines = text.split("\n")
norm_text = (
text.replace(r"\r\n", "\n")
.replace(r"\n", "\n")
.replace(r"\r", "\n")
)
lines = norm_text.split("\n")
for line_idx, line in enumerate(lines):
if line_idx > 0:
self.device.shell("input keyevent 66")
+10
View File
@@ -14,6 +14,12 @@
"""Artemis Diagnostics & System Readiness Package."""
from artemis.core.diagnostics.adb_keys import (
AdbKeyStatus,
get_adb_key_paths,
heal_adb_keys,
inspect_adb_keys,
)
from artemis.core.diagnostics.emulator_manager import (
EmulatorLaunchStage,
EmulatorLaunchState,
@@ -59,4 +65,8 @@ __all__ = [
"ProbeResult",
"ProbeStatus",
"SystemReadinessReport",
"AdbKeyStatus",
"get_adb_key_paths",
"inspect_adb_keys",
"heal_adb_keys",
]
+267
View File
@@ -0,0 +1,267 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""ADB Authentication Key Diagnostic and Automated Self-Healing Module.
Android 4.2.2+ requires RSA authentication keys (~/.android/adbkey) for USB debugging.
When key files become 0-bytes or corrupted (due to aborted writes, touch commands,
or disk sync issues), ADB fails to send the public key, causing connected devices
to silently remain in an 'unauthorized' state without ever displaying the authorization prompt.
This module provides detection and self-healing for corrupted ADB keys.
"""
from dataclasses import asdict, dataclass
import os
from pathlib import Path
import shutil
import subprocess
import time
from typing import Any
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@dataclass
class AdbKeyStatus:
"""Status details for ADB authentication keys."""
is_valid: bool
is_corrupted: bool
exists: bool
key_path: str
pub_key_path: str
key_size_bytes: int = 0
pub_key_size_bytes: int = 0
error_reason: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def get_adb_key_paths() -> tuple[Path, Path]:
"""Resolve the paths to the active ADB private and public key files."""
adb_vendor_keys = os.environ.get("ADB_VENDOR_KEYS")
if adb_vendor_keys:
custom_priv = Path(adb_vendor_keys.split(os.pathsep)[0])
custom_pub = Path(f"{custom_priv}.pub")
return custom_priv, custom_pub
android_dir = Path.home() / ".android"
return android_dir / "adbkey", android_dir / "adbkey.pub"
def inspect_adb_keys() -> AdbKeyStatus:
"""Inspect ADB authentication RSA key files for corruption or invalid state."""
priv_path, pub_path = get_adb_key_paths()
if not priv_path.exists() and not pub_path.exists():
# Fresh environment: ADB will automatically generate keys on first start
return AdbKeyStatus(
is_valid=True,
is_corrupted=False,
exists=False,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=0,
pub_key_size_bytes=0,
error_reason=None,
)
priv_size = priv_path.stat().st_size if priv_path.exists() else 0
pub_size = pub_path.stat().st_size if pub_path.exists() else 0
# 1. Check for 0-byte or truncated private key file
if priv_path.exists():
if priv_size == 0:
return AdbKeyStatus(
is_valid=False,
is_corrupted=True,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=0,
pub_key_size_bytes=pub_size,
error_reason="Private key file is 0 bytes (empty). ADB cannot authenticate devices.",
)
if priv_size < 100:
return AdbKeyStatus(
is_valid=False,
is_corrupted=True,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=priv_size,
pub_key_size_bytes=pub_size,
error_reason=f"Private key file is suspiciously small ({priv_size} bytes).",
)
# Validate PEM header structure
try:
content = priv_path.read_text(encoding="utf-8", errors="replace")
if "BEGIN" not in content or "PRIVATE KEY" not in content:
return AdbKeyStatus(
is_valid=False,
is_corrupted=True,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=priv_size,
pub_key_size_bytes=pub_size,
error_reason="Private key does not contain a valid PEM private key header.",
)
except Exception as e:
return AdbKeyStatus(
is_valid=False,
is_corrupted=True,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=priv_size,
pub_key_size_bytes=pub_size,
error_reason=f"Cannot read private key: {e}",
)
# 2. Check for 0-byte public key if it exists
if pub_path.exists() and pub_size == 0:
return AdbKeyStatus(
is_valid=False,
is_corrupted=True,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=priv_size,
pub_key_size_bytes=0,
error_reason="Public key file (adbkey.pub) is 0 bytes (empty).",
)
return AdbKeyStatus(
is_valid=True,
is_corrupted=False,
exists=True,
key_path=str(priv_path),
pub_key_path=str(pub_path),
key_size_bytes=priv_size,
pub_key_size_bytes=pub_size,
error_reason=None,
)
def heal_adb_keys(
adb_path: str | None = None, force: bool = False
) -> dict[str, Any]:
"""Auto-heal corrupted ADB authentication keys.
If keys are corrupted (or if `force=True`), this function:
1. Backs up or removes corrupted key files.
2. Restarts the ADB server to cleanly generate a fresh RSA 2048-bit key pair.
3. Verifies that the new key pair is healthy and valid.
"""
status = inspect_adb_keys()
if not status.is_corrupted and not force:
return {
"success": True,
"repaired": False,
"message": "ADB keys are healthy, no repair required.",
"status": status.to_dict(),
}
priv_path, pub_path = get_adb_key_paths()
priv_path.parent.mkdir(parents=True, exist_ok=True)
# Backup corrupted non-empty files before removal
timestamp = int(time.time())
if priv_path.exists() and priv_path.stat().st_size > 0:
backup_path = priv_path.with_name(f"adbkey.corrupted.{timestamp}")
try:
shutil.copy2(priv_path, backup_path)
logger.info(f"Backed up corrupted adbkey to {backup_path}")
except Exception as e:
logger.warning(f"Could not backup corrupted adbkey: {e}")
# Remove corrupted key files
try:
if priv_path.exists():
priv_path.unlink(missing_ok=True)
if pub_path.exists():
pub_path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"Failed to remove corrupted adb keys: {e}")
return {
"success": False,
"repaired": False,
"message": f"Failed to remove corrupted ADB key files: {e}",
"status": status.to_dict(),
}
# Resolve ADB executable
resolved_adb = adb_path or toolchain.resolve("adb") or shutil.which("adb") or "adb"
# Restart ADB server to trigger key generation
try:
subprocess.run(
[resolved_adb, "kill-server"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
subprocess.run(
[resolved_adb, "start-server"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
# Execute adb devices to trigger initial handshake & key material generation if needed
subprocess.run(
[resolved_adb, "devices"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except Exception as e:
logger.error(f"Error restarting ADB server during key healing: {e}")
return {
"success": False,
"repaired": False,
"message": f"Failed to restart ADB server: {e}",
"status": status.to_dict(),
}
# Re-inspect to verify health
new_status = inspect_adb_keys()
if new_status.is_valid and new_status.key_size_bytes > 0:
logger.info("Successfully healed corrupted ADB keys.")
return {
"success": True,
"repaired": True,
"message": "Corrupted ADB keys were successfully repaired and regenerated.",
"status": new_status.to_dict(),
}
elif new_status.is_valid and not new_status.is_corrupted:
return {
"success": True,
"repaired": True,
"message": "Corrupted ADB keys cleared. Fresh keys will be created on first device connection.",
"status": new_status.to_dict(),
}
else:
return {
"success": False,
"repaired": False,
"message": f"Key healing completed, but keys remain invalid: {new_status.error_reason}",
"status": new_status.to_dict(),
}
+18 -1
View File
@@ -133,11 +133,28 @@ class ReadinessEngine:
timestamp=time.time(),
)
async def heal_adb_keys(self, force: bool = False) -> dict[str, Any]:
"""Auto-heal corrupted or 0-byte ADB authentication keys."""
from artemis.core.diagnostics.adb_keys import heal_adb_keys
logger.info("[ReadinessEngine] Auto-healing ADB authentication keys...")
return await asyncio.to_thread(heal_adb_keys, None, force)
async def restart_adb_server(self) -> dict[str, Any]:
"""Execute adb kill-server && adb start-server to recover connectivity."""
"""Execute adb kill-server && adb start-server to recover connectivity, auto-healing corrupted keys if needed."""
from artemis.core.diagnostics.adb_keys import heal_adb_keys, inspect_adb_keys
adb_path = shutil.which("adb") or "adb"
def _restart_sync():
# If keys are corrupted, heal them first
key_status = inspect_adb_keys()
if key_status.is_corrupted:
logger.warning(
f"[ReadinessEngine] Corrupted ADB keys detected ({key_status.error_reason}). Auto-healing..."
)
return heal_adb_keys(adb_path=adb_path)
try:
subprocess.run(
[adb_path, "kill-server"],
@@ -546,6 +546,11 @@ class AdbDeviceProbe(BaseProbe):
adb_ver = await self._get_adb_version(adb_path)
devices = await self._parse_adb_devices(adb_path)
# Inspect ADB authentication key health
from artemis.core.diagnostics.adb_keys import inspect_adb_keys
key_status = inspect_adb_keys()
# Normalize emulator command display
is_emu_in_path = shutil.which("emulator") is not None
emu_display_cmd = "emulator" if is_emu_in_path else (emulator_path or "emulator")
@@ -554,6 +559,7 @@ class AdbDeviceProbe(BaseProbe):
"installed": True,
"adb_path": adb_path,
"adb_version": adb_ver,
"adb_keys": key_status.to_dict(),
"emulator_path": emulator_path,
"is_emulator_in_path": is_emu_in_path,
"installed_avds": installed_avds,
@@ -565,6 +571,15 @@ class AdbDeviceProbe(BaseProbe):
if not devices:
actions: list[ProbeAction] = []
if key_status.is_corrupted:
actions.append(
ProbeAction(
action_type="command",
label="Auto-Heal ADB Keys",
payload="artemis doctor --fix",
)
)
# If user has installed AVDs, generate exact launch commands for each
if installed_avds:
for avd in installed_avds:
@@ -593,6 +608,8 @@ class AdbDeviceProbe(BaseProbe):
)
desc = "ADB is ready, but no active Android device or emulator was detected."
if key_status.is_corrupted:
desc += f" Warning: Corrupted ADB RSA key detected ({key_status.error_reason})."
if installed_avds:
desc += f" Found {len(installed_avds)} installed emulator(s): {', '.join(installed_avds)}."
@@ -615,6 +632,33 @@ class AdbDeviceProbe(BaseProbe):
if not ready_devices:
if unauthorized_devices:
unauth_serial = unauthorized_devices[0].serial
if key_status.is_corrupted:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="ADB Key Corrupted",
description=(
f"Device detected ({unauth_serial}), but ADB authentication keys are corrupted "
f"({key_status.error_reason}). This prevents the device from displaying the USB Debugging prompt."
),
metadata=metadata,
actions=[
ProbeAction(
action_type="command",
label="Auto-Heal ADB Keys",
payload="artemis doctor --fix",
),
ProbeAction(
action_type="command",
label="Restart ADB Server",
payload=f"{adb_path} kill-server && {adb_path} start-server",
),
],
)
return ProbeResult(
id=self.probe_id,
category=self.category,
+25 -24
View File
@@ -322,46 +322,47 @@ class AndroidAdbDriver(BaseDeviceDriver):
"input keyevent 123", # KEYCODE_MOVE_END
)
# 1. Fast path for ASCII text: Use ADB input text directly.
# This avoids toggling IME, preserves keyboard focus, and works on all Android devices.
if text.isascii():
lines = text.split("\n")
for i, line in enumerate(lines):
if i > 0:
# Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
return True
# Normalize literal escaped newlines from LLM / tool call serialization
norm_text = (
text.replace(r"\r\n", "\n")
.replace(r"\n", "\n")
.replace(r"\r", "\n")
)
# 2. For non-ASCII / Unicode text: Try UIAutomator2 FastInputIME
if self._ui_adb_client and hasattr(self._ui_adb_client, "send_text"):
# 1. Tier 1: Try clipboard injection + KEYCODE_PASTE (Zero IME interference, preserves multiline, works for all charsets)
if self._ui_adb_client:
try:
res = self._ui_adb_client.send_text(text)
if asyncio.iscoroutine(res):
await res
return True
except Exception as e:
logger.warning(f"UIAutomator2 send_text failed: {e}")
set_clip_ok = False
if hasattr(self._ui_adb_client, "set_clipboard"):
set_clip_ok = self._ui_adb_client.set_clipboard(norm_text)
elif hasattr(self._ui_adb_client, "_device") and self._ui_adb_client._device:
self._ui_adb_client._device.set_clipboard(norm_text)
set_clip_ok = True
# 3. Check if ADBKeyboard is currently active
if set_clip_ok:
await asyncio.to_thread(self.device.shell, "input keyevent 279")
return True
except Exception as e:
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
# 2. Tier 2: Check if ADBKeyboard is currently active
try:
default_ime = await asyncio.to_thread(
self.device.shell, "settings get secure default_input_method"
)
if "adbkeyboard" in str(default_ime).lower():
b64_text = base64.b64encode(text.encode("utf-8")).decode("utf-8")
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
await asyncio.to_thread(self.device.shell, broadcast_cmd)
return True
except Exception:
pass
# 4. Fallback: ADB input text
lines = text.split("\n")
# 3. Tier 3: Universal Native ADB input text fallback
lines = norm_text.split("\n")
for i, line in enumerate(lines):
if i > 0:
# Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
+48 -1
View File
@@ -22,18 +22,27 @@ import sys
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
import typer
from artemis.config import (
parse_llm_config,
settings,
)
from artemis.config.paths import ROOT_DIR
from artemis.core.diagnostics.adb_keys import heal_adb_keys, inspect_adb_keys
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.ocr_api import is_ocr_configured
def doctor_command() -> None:
def doctor_command(
fix: bool = typer.Option(
False,
"--fix",
"-f",
help="Automatically repair fixable environment issues (such as corrupted ADB authentication keys).",
),
) -> None:
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
console = Console()
console.print()
@@ -103,6 +112,44 @@ def doctor_command() -> None:
f"adb not found in PATH or Android SDK. {hint}",
)
# 2b. ADB Authentication RSA Keys Check
key_status = inspect_adb_keys()
if key_status.is_corrupted:
if fix:
heal_result = heal_adb_keys(adb_path=adb_path)
if heal_result.get("success"):
table.add_row(
"ADB Auth Keys",
"[bold green]✔ Repaired[/bold green]",
"Corrupted RSA keys detected and automatically regenerated.",
)
else:
all_passed = False
table.add_row(
"ADB Auth Keys",
"[bold red]✖ Error[/bold red]",
f"Failed to auto-heal keys: {heal_result.get('message')}",
)
else:
all_passed = False
table.add_row(
"ADB Auth Keys",
"[bold red]✖ Corrupted[/bold red]",
f"{key_status.error_reason} Run [bold cyan]artemis doctor --fix[/bold cyan] to auto-repair.",
)
elif key_status.exists:
table.add_row(
"ADB Auth Keys",
"[bold green]✔ OK[/bold green]",
f"{key_status.key_path} ({key_status.key_size_bytes} bytes RSA)",
)
else:
table.add_row(
"ADB Auth Keys",
"[bold cyan]○ Ready[/bold cyan]",
"Keys will be generated automatically on first device connection.",
)
# 3. Connected Android Devices
devices_info: list[tuple[str, str, str]] = []
if adb_path:
@@ -0,0 +1,53 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from unittest.mock import patch, MagicMock
import pytest
from apps.admin_console.services.model_service import ModelService
def test_get_active_model_info_pro_architecture():
"""Verify that pro profile returns Pro architecture while keeping real LLM model."""
info = ModelService.get_active_model_info("pro")
assert info["name"] == "Pro"
assert info["architecture"] == "ARTEMIS Pro"
assert info["provider"] == "google"
assert "id" in info
def test_get_active_model_info_flash_architecture():
"""Verify that flash profile returns Flash architecture."""
info = ModelService.get_active_model_info("flash")
assert info["name"] == "Flash"
assert info["architecture"] == "ARTEMIS Flash"
assert info["provider"] == "google"
def test_resolve_session_profile_from_device_info():
"""Verify profile resolution from device_info JSON."""
row = {"device_info": json.dumps({"profile": "pro"})}
assert ModelService.resolve_session_profile(row) == "pro"
row_flash = {"device_info": json.dumps({"profile": "flash"})}
assert ModelService.resolve_session_profile(row_flash) == "flash"
def test_resolve_session_profile_from_agent_names():
"""Verify profile resolution from agent/trace names."""
row = {"device_info": None}
assert ModelService.resolve_session_profile(row, agent_names=["planner", "operator"]) == "pro"
assert ModelService.resolve_session_profile(row, agent_names=["flashrunner"]) == "flash"
+198
View File
@@ -0,0 +1,198 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ADB RSA key inspection and automated self-healing."""
from pathlib import Path
from unittest.mock import patch
import pytest
from artemis.core.diagnostics.adb_keys import (
AdbKeyStatus,
get_adb_key_paths,
heal_adb_keys,
inspect_adb_keys,
)
from artemis.core.diagnostics.engine import ReadinessEngine
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.schema import DeviceInfo, ProbeStatus
def test_get_adb_key_paths_default():
"""Verify default ADB key paths are under ~/.android."""
priv, pub = get_adb_key_paths()
assert priv.name == "adbkey"
assert pub.name == "adbkey.pub"
assert ".android" in str(priv)
def test_get_adb_key_paths_custom_env(monkeypatch, tmp_path):
"""Verify custom ADB_VENDOR_KEYS environment variable is respected."""
custom_key = tmp_path / "custom_adbkey"
monkeypatch.setenv("ADB_VENDOR_KEYS", str(custom_key))
priv, pub = get_adb_key_paths()
assert priv == custom_key
assert pub == Path(f"{custom_key}.pub")
def test_inspect_adb_keys_not_exist(monkeypatch, tmp_path):
"""When keys do not exist yet, they are considered valid (will auto-generate on first connect)."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
status = inspect_adb_keys()
assert status.is_valid is True
assert status.is_corrupted is False
assert status.exists is False
def test_inspect_adb_keys_zero_bytes(monkeypatch, tmp_path):
"""When private key is 0-bytes, it must be detected as corrupted."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text("")
pub.write_text("")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
status = inspect_adb_keys()
assert status.is_valid is False
assert status.is_corrupted is True
assert status.exists is True
assert status.key_size_bytes == 0
assert "0 bytes" in (status.error_reason or "")
def test_inspect_adb_keys_invalid_pem_header(monkeypatch, tmp_path):
"""When private key is non-empty but does not contain PEM headers, it is corrupted."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text("GARBAGE DATA WITHOUT PEM HEADER " * 10)
pub.write_text("some pubkey")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
status = inspect_adb_keys()
assert status.is_valid is False
assert status.is_corrupted is True
assert "PEM" in (status.error_reason or "")
def test_inspect_adb_keys_valid(monkeypatch, tmp_path):
"""When private key has valid PEM structure and size, it is valid."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text(
"-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC...\n-----END PRIVATE KEY-----\n"
)
pub.write_text("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
status = inspect_adb_keys()
assert status.is_valid is True
assert status.is_corrupted is False
assert status.exists is True
assert status.key_size_bytes > 0
assert status.error_reason is None
def test_heal_adb_keys_healthy_no_op(monkeypatch, tmp_path):
"""When keys are healthy and force is False, heal_adb_keys is a no-op."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text(
"-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC...\n-----END PRIVATE KEY-----\n"
)
pub.write_text("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
res = heal_adb_keys(force=False)
assert res["success"] is True
assert res["repaired"] is False
def test_heal_adb_keys_corrupted(monkeypatch, tmp_path):
"""When keys are corrupted (0 bytes), heal_adb_keys deletes them and restarts ADB."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text("")
pub.write_text("")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
with patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
res = heal_adb_keys()
assert res["success"] is True
assert res["repaired"] is True
# The corrupted 0-byte file was removed
assert not priv.exists()
@pytest.mark.asyncio
async def test_adb_probe_detects_corrupted_key_with_unauthorized_device(monkeypatch, tmp_path):
"""When a device is unauthorized and keys are corrupted, probe reports ADB Key Corrupted."""
priv = tmp_path / "adbkey"
pub = tmp_path / "adbkey.pub"
priv.write_text("")
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.get_adb_key_paths", lambda: (priv, pub)
)
probe = AdbDeviceProbe()
monkeypatch.setattr(probe, "_locate_adb", lambda: "/usr/bin/adb")
async def mock_adb_ver(p):
return "Android Debug Bridge version 1.0.41"
monkeypatch.setattr(probe, "_get_adb_version", mock_adb_ver)
async def mock_devices(adb_path):
return [DeviceInfo(serial="TEST_SERIAL", state="unauthorized")]
monkeypatch.setattr(probe, "_parse_adb_devices", mock_devices)
result = await probe.probe()
assert result.status == ProbeStatus.WARN
assert result.summary == "ADB Key Corrupted"
assert "0 bytes" in result.description
assert any(a.label == "Auto-Heal ADB Keys" for a in result.actions)
@pytest.mark.asyncio
async def test_readiness_engine_heal_adb_keys():
"""Verify ReadinessEngine exposes heal_adb_keys async method."""
engine = ReadinessEngine()
with patch("artemis.core.diagnostics.adb_keys.heal_adb_keys") as mock_heal:
mock_heal.return_value = {"success": True, "repaired": True, "message": "Repaired"}
res = await engine.heal_adb_keys()
assert res["success"] is True
assert res["repaired"] is True