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:
1 parent
42b8988661
commit
ea3708a98f
19 files changed
+785
-89
No files matched your search
@@ -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;">
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user