chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a small AirXDB Computer MCP smoke test with screenshot artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
REQUIRED_BASE_ENV = [
|
||||
"MIDSCENE_MODEL_NAME",
|
||||
"MIDSCENE_MODEL_BASE_URL",
|
||||
"MIDSCENE_MODEL_API_KEY",
|
||||
]
|
||||
REQUIRED_SEMANTIC_ENV = [
|
||||
*REQUIRED_BASE_ENV,
|
||||
"MIDSCENE_MODEL_FAMILY",
|
||||
]
|
||||
VALID_MODEL_FAMILIES = [
|
||||
"doubao-vision",
|
||||
"doubao-seed",
|
||||
"gemini",
|
||||
"qwen2.5-vl",
|
||||
"qwen3-vl",
|
||||
"qwen3.5",
|
||||
"qwen3.6",
|
||||
"vlm-ui-tars",
|
||||
"vlm-ui-tars-doubao",
|
||||
"vlm-ui-tars-doubao-1.5",
|
||||
"glm-v",
|
||||
"auto-glm",
|
||||
"auto-glm-multilingual",
|
||||
"gpt-5",
|
||||
]
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def load_env(project_root: Path) -> Dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env_file = project_root / "AirPlan" / "state" / "airxdb" / "midscene.local.env"
|
||||
if not env_file.exists():
|
||||
return env
|
||||
|
||||
for raw_line in env_file.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and value and key not in env:
|
||||
env[key] = value
|
||||
return env
|
||||
|
||||
|
||||
def infer_model_family(model_name: str) -> str:
|
||||
normalized = model_name.strip().lower()
|
||||
if normalized.startswith("gpt-5"):
|
||||
return "gpt-5"
|
||||
if "qwen3" in normalized:
|
||||
return "qwen3-vl"
|
||||
if "qwen2.5" in normalized or "qwen-2.5" in normalized:
|
||||
return "qwen2.5-vl"
|
||||
if "gemini" in normalized:
|
||||
return "gemini"
|
||||
if "doubao" in normalized:
|
||||
return "doubao-seed"
|
||||
if "glm" in normalized:
|
||||
return "glm-v"
|
||||
return ""
|
||||
|
||||
|
||||
def validate_env(env: Dict[str, str], semantic: bool) -> List[str]:
|
||||
if not semantic:
|
||||
return []
|
||||
required = REQUIRED_SEMANTIC_ENV if semantic else REQUIRED_BASE_ENV
|
||||
missing = [name for name in required if not env.get(name)]
|
||||
family = env.get("MIDSCENE_MODEL_FAMILY", "")
|
||||
if semantic and family and family not in VALID_MODEL_FAMILIES:
|
||||
missing.append("MIDSCENE_MODEL_FAMILY(valid value)")
|
||||
return missing
|
||||
|
||||
|
||||
def redact(text: str, env: Dict[str, str]) -> str:
|
||||
secret = env.get("MIDSCENE_MODEL_API_KEY")
|
||||
if secret:
|
||||
text = text.replace(secret, "<redacted>")
|
||||
return text
|
||||
|
||||
|
||||
def command_name(name: str) -> str:
|
||||
if os.name == "nt":
|
||||
candidate = shutil.which(f"{name}.cmd")
|
||||
if candidate:
|
||||
return candidate
|
||||
return shutil.which(name) or name
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return int(port)
|
||||
|
||||
|
||||
def npm_cache_root(env: Dict[str, str]) -> Optional[Path]:
|
||||
local_app_data = env.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return Path(local_app_data) / "npm-cache"
|
||||
home = env.get("HOME") or env.get("USERPROFILE")
|
||||
return Path(home) / ".npm" if home else None
|
||||
|
||||
|
||||
def midscene_dist_dirs(project_root: Path, env: Dict[str, str]) -> List[Path]:
|
||||
candidates: List[Path] = []
|
||||
local = project_root / "node_modules" / "@midscene" / "computer-mcp" / "dist"
|
||||
if local.exists():
|
||||
candidates.append(local)
|
||||
|
||||
cache = npm_cache_root(env)
|
||||
if cache:
|
||||
candidates.extend(cache.glob("_npx/*/node_modules/@midscene/computer-mcp/dist"))
|
||||
|
||||
unique: List[Path] = []
|
||||
seen = set()
|
||||
for path in candidates:
|
||||
resolved = str(path.resolve())
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def ensure_windows_screenshot_assets(project_root: Path, env: Dict[str, str]) -> Tuple[str, List[str]]:
|
||||
if os.name != "nt":
|
||||
return "skipped-non-windows", []
|
||||
|
||||
dist_dirs = midscene_dist_dirs(project_root, env)
|
||||
missing_dirs = [
|
||||
path for path in dist_dirs
|
||||
if not (path / "screenCapture_1.3.2.bat").exists() or not (path / "app.manifest").exists()
|
||||
]
|
||||
if not missing_dirs:
|
||||
return "ok", [str(path) for path in dist_dirs]
|
||||
if not dist_dirs:
|
||||
return "missing-midscene-package", []
|
||||
|
||||
npm = command_name("npm")
|
||||
with tempfile.TemporaryDirectory(prefix="airxdb-screenshot-desktop-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
subprocess.run(
|
||||
[npm, "pack", "screenshot-desktop@1.15.3", "--pack-destination", str(tmp_path)],
|
||||
cwd=str(project_root),
|
||||
env=env,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
tgz = next(tmp_path.glob("screenshot-desktop-1.15.3.tgz"))
|
||||
with tarfile.open(tgz, "r:gz") as archive:
|
||||
archive.extract("package/lib/win32/screenCapture_1.3.2.bat", path=tmp_path)
|
||||
archive.extract("package/lib/win32/app.manifest", path=tmp_path)
|
||||
bat = tmp_path / "package" / "lib" / "win32" / "screenCapture_1.3.2.bat"
|
||||
manifest = tmp_path / "package" / "lib" / "win32" / "app.manifest"
|
||||
for dist in missing_dirs:
|
||||
shutil.copy2(bat, dist / "screenCapture_1.3.2.bat")
|
||||
shutil.copy2(manifest, dist / "app.manifest")
|
||||
return "repaired", [str(path) for path in missing_dirs]
|
||||
|
||||
|
||||
def decode_response(raw: str, content_type: str) -> Optional[Dict[str, Any]]:
|
||||
if "text/event-stream" in content_type or raw.startswith("event:") or raw.startswith("data:"):
|
||||
data_lines = [line[5:].strip() for line in raw.splitlines() if line.startswith("data:")]
|
||||
raw = "\n".join(data_lines).strip()
|
||||
return json.loads(raw) if raw.strip() else None
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self, url: str, env: Dict[str, str], timeout: int) -> None:
|
||||
self.url = url
|
||||
self.env = env
|
||||
self.timeout = timeout
|
||||
self.session_id: Optional[str] = None
|
||||
self.seq = 1
|
||||
|
||||
def post(self, method: str, params: Optional[Dict[str, Any]] = None, expect_id: bool = True) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
|
||||
if expect_id:
|
||||
payload["id"] = self.seq
|
||||
self.seq += 1
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if self.session_id:
|
||||
headers["mcp-session-id"] = self.session_id
|
||||
|
||||
request = urllib.request.Request(
|
||||
self.url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
raw = redact(raw, self.env)
|
||||
self.session_id = response.headers.get("mcp-session-id") or self.session_id
|
||||
return decode_response(raw, response.headers.get("content-type", ""))
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read().decode("utf-8", "replace")
|
||||
raise RuntimeError(f"HTTP {error.code} for {method}: {redact(raw, self.env)}") from error
|
||||
|
||||
def call_tool(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
return self.post("tools/call", {"name": name, "arguments": arguments or {}})
|
||||
|
||||
|
||||
def content_texts(result: Optional[Dict[str, Any]]) -> List[str]:
|
||||
content = (result or {}).get("result", {}).get("content", [])
|
||||
return [item.get("text", "") for item in content if item.get("type") == "text"]
|
||||
|
||||
|
||||
def save_images(result: Optional[Dict[str, Any]], output_dir: Path, stem: str) -> List[str]:
|
||||
saved: List[str] = []
|
||||
content = (result or {}).get("result", {}).get("content", [])
|
||||
for index, item in enumerate(content):
|
||||
if item.get("type") != "image" or not item.get("data"):
|
||||
continue
|
||||
mime = item.get("mimeType", "image/png")
|
||||
ext = ".jpg" if "jpeg" in mime or "jpg" in mime else ".png"
|
||||
path = output_dir / f"{stem}-{index}{ext}"
|
||||
path.write_bytes(base64.b64decode(item["data"]))
|
||||
saved.append(str(path))
|
||||
return saved
|
||||
|
||||
|
||||
def wait_for_port(port: int, proc: subprocess.Popen[str], logs: List[str], timeout: int) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"computer-mcp exited {proc.returncode}\n" + "\n".join(logs[-80:]))
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
raise TimeoutError("computer-mcp HTTP server did not open a port\n" + "\n".join(logs[-80:]))
|
||||
|
||||
|
||||
def start_log_pump(stream: Any, prefix: str, logs: List[str], env: Dict[str, str]) -> None:
|
||||
def pump() -> None:
|
||||
for line in iter(stream.readline, ""):
|
||||
logs.append(prefix + redact(line.rstrip(), env))
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run AirXDB Computer MCP smoke test.")
|
||||
parser.add_argument("--project", default=".")
|
||||
parser.add_argument("--prompt", default="Windows taskbar Start button")
|
||||
parser.add_argument("--action", choices=["connect", "screenshot", "mousemove", "act"], default="mousemove")
|
||||
parser.add_argument("--output-dir", default="")
|
||||
parser.add_argument("--timeout", type=int, default=240)
|
||||
parser.add_argument("--no-repair-screenshot-assets", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else project_root / "AirPlan" / "docs" / "debug" / "airxdb-artifacts"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = load_env(project_root)
|
||||
semantic = args.action in {"mousemove", "act"}
|
||||
missing = validate_env(env, semantic)
|
||||
if missing:
|
||||
print("airxdb_smoke=blocked")
|
||||
print("missing_midscene_env=" + ",".join(missing))
|
||||
suggestion = infer_model_family(env.get("MIDSCENE_MODEL_NAME", ""))
|
||||
if suggestion:
|
||||
print(f"suggested_midscene_model_family={suggestion}")
|
||||
print("valid_midscene_model_families=" + ",".join(VALID_MODEL_FAMILIES))
|
||||
raise SystemExit(2)
|
||||
|
||||
port = free_port()
|
||||
logs: List[str] = []
|
||||
npx = command_name("npx")
|
||||
proc = subprocess.Popen(
|
||||
[npx, "-y", "@midscene/computer-mcp", "--mode", "http", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=str(project_root),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
assert proc.stderr is not None
|
||||
start_log_pump(proc.stdout, "OUT ", logs, env)
|
||||
start_log_pump(proc.stderr, "ERR ", logs, env)
|
||||
|
||||
steps: List[Dict[str, Any]] = []
|
||||
asset_status = "skipped"
|
||||
asset_paths: List[str] = []
|
||||
try:
|
||||
wait_for_port(port, proc, logs, min(args.timeout, 60))
|
||||
if not args.no_repair_screenshot_assets:
|
||||
asset_status, asset_paths = ensure_windows_screenshot_assets(project_root, env)
|
||||
|
||||
client = MCPClient(f"http://127.0.0.1:{port}/mcp", env, args.timeout)
|
||||
client.post(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "airxdb-computer-mcp-smoke", "version": "0.2.1"},
|
||||
},
|
||||
)
|
||||
client.post("notifications/initialized", {}, expect_id=False)
|
||||
|
||||
actions: List[Tuple[str, Dict[str, Any], str]] = [
|
||||
("computer_connect", {}, "connect"),
|
||||
]
|
||||
if args.action == "mousemove":
|
||||
actions.append(("MouseMove", {"locate": {"prompt": args.prompt}}, "mousemove"))
|
||||
elif args.action == "act":
|
||||
actions.append(("act", {"prompt": args.prompt}, "act"))
|
||||
if args.action in {"screenshot", "mousemove", "act"}:
|
||||
actions.append(("take_screenshot", {}, "final"))
|
||||
|
||||
for tool, arguments, stem in actions:
|
||||
result = client.call_tool(tool, arguments)
|
||||
steps.append(
|
||||
{
|
||||
"tool": tool,
|
||||
"arguments": arguments,
|
||||
"texts": content_texts(result),
|
||||
"images": save_images(result, output_dir, f"{now_stamp()}-{stem}"),
|
||||
}
|
||||
)
|
||||
time.sleep(0.3)
|
||||
|
||||
try:
|
||||
result = client.call_tool("computer_disconnect", {})
|
||||
steps.append({"tool": "computer_disconnect", "arguments": {}, "texts": content_texts(result), "images": []})
|
||||
except Exception as error:
|
||||
steps.append({"tool": "computer_disconnect", "arguments": {}, "texts": [str(error)], "images": []})
|
||||
|
||||
failed = [
|
||||
step for step in steps
|
||||
if any(text.lower().startswith(("warning:", "failed", "error")) for text in step["texts"])
|
||||
]
|
||||
status = "failed" if failed else "ok"
|
||||
report = {
|
||||
"status": status,
|
||||
"action": args.action,
|
||||
"prompt": args.prompt,
|
||||
"modelName": env.get("MIDSCENE_MODEL_NAME", ""),
|
||||
"modelFamily": env.get("MIDSCENE_MODEL_FAMILY", ""),
|
||||
"assetRepair": {"status": asset_status, "paths": asset_paths},
|
||||
"steps": steps,
|
||||
"logsTail": logs[-80:],
|
||||
}
|
||||
report_path = output_dir / f"{now_stamp()}-airxdb-smoke.json"
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"airxdb_smoke={status}")
|
||||
print(f"capture_mode={'semantic' if semantic else 'screenshot'}")
|
||||
print(f"report={report_path}")
|
||||
print(f"asset_repair={asset_status}")
|
||||
for step in steps:
|
||||
print(f"step={step['tool']}")
|
||||
for text in step["texts"]:
|
||||
print("text=" + text.replace("\n", " | ")[:1600])
|
||||
for image in step["images"]:
|
||||
print(f"image={image}")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
429
AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_mode.py
Executable file
429
AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_mode.py
Executable file
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bootstrap AirXDB GUI debug context files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
MARKER_BEGIN = "<!-- AIRXDB:BEGIN -->"
|
||||
MARKER_END = "<!-- AIRXDB:END -->"
|
||||
REQUIRED_MIDSCENE_ENV = [
|
||||
"MIDSCENE_MODEL_NAME",
|
||||
"MIDSCENE_MODEL_BASE_URL",
|
||||
"MIDSCENE_MODEL_API_KEY",
|
||||
]
|
||||
SEMANTIC_MIDSCENE_ENV = [
|
||||
*REQUIRED_MIDSCENE_ENV,
|
||||
"MIDSCENE_MODEL_FAMILY",
|
||||
]
|
||||
OPTIONAL_MIDSCENE_ENV = [
|
||||
"MCP_SERVER_REQUEST_TIMEOUT",
|
||||
]
|
||||
MODEL_FAMILY_VALUES = [
|
||||
"doubao-vision",
|
||||
"doubao-seed",
|
||||
"gemini",
|
||||
"qwen2.5-vl",
|
||||
"qwen3-vl",
|
||||
"qwen3.5",
|
||||
"qwen3.6",
|
||||
"vlm-ui-tars",
|
||||
"vlm-ui-tars-doubao",
|
||||
"vlm-ui-tars-doubao-1.5",
|
||||
"glm-v",
|
||||
"auto-glm",
|
||||
"auto-glm-multilingual",
|
||||
"gpt-5",
|
||||
]
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def airxdb_agents_block() -> str:
|
||||
return f"""{MARKER_BEGIN}
|
||||
## AirXDB GUI Debug Workflow
|
||||
|
||||
1. Use AirXDB for `/airxdb` sessions that debug browser or desktop GUI issues with Midscene.js.
|
||||
2. Before working, load:
|
||||
- `AirPlan/AGENTS.md`
|
||||
- `AirPlan/docs/architecture/adr/`
|
||||
- `AirPlan/docs/architecture/c4/module.md`
|
||||
- `AirPlan/docs/debug/gui-debug-log.md`
|
||||
3. Classify the target first:
|
||||
- Web + Playwright
|
||||
- Web + Chrome Bridge
|
||||
- Desktop Computer / Playground
|
||||
- MCP
|
||||
4. Before semantic visual actions, require Midscene model config:
|
||||
- `MIDSCENE_MODEL_NAME`
|
||||
- `MIDSCENE_MODEL_BASE_URL`
|
||||
- `MIDSCENE_MODEL_API_KEY`
|
||||
- `MIDSCENE_MODEL_FAMILY`
|
||||
5. Screenshot capture is allowed without semantic model config and should be used as AirDbg diagnostic evidence.
|
||||
6. Reproduce visually first and keep Midscene report paths, screenshots, and commands.
|
||||
7. Use AirXDB with AirDbg when GUI reproduction and code-level fixing are both needed.
|
||||
8. Update ADR when long-term GUI automation or bridge/MCP choices become architecture context.
|
||||
9. Update C4 module when UI automation boundaries, browser bridge layers, or desktop control boundaries change.
|
||||
10. Keep GUI debug logs resumable and concise.
|
||||
{MARKER_END}
|
||||
"""
|
||||
|
||||
|
||||
def c4_module_template() -> str:
|
||||
return """# C4 Module
|
||||
|
||||
## System Context
|
||||
- TODO: Describe the product, users, and UI surfaces involved in the GUI issue.
|
||||
|
||||
## Containers
|
||||
- TODO: Describe browser, frontend app, desktop app, automation runner, and external services.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Responsibility | Public Interfaces | Dependencies | Data Ownership | GUI Debug Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| TODO | TODO | TODO | TODO | TODO | TODO |
|
||||
|
||||
## Automation / Observability Boundaries
|
||||
- TODO: Record Playwright, Bridge, Desktop, MCP, report generation, and screenshot boundaries.
|
||||
|
||||
## Change Log
|
||||
- TODO: Record GUI-debug-related boundary changes.
|
||||
"""
|
||||
|
||||
|
||||
def adr_template() -> str:
|
||||
return """# ADR-0001: AirXDB GUI Debug Governance
|
||||
|
||||
- Status: Accepted
|
||||
- Date: TODO
|
||||
|
||||
## Context
|
||||
GUI debugging needs stable visual reproduction, report evidence, and durable project context across sessions.
|
||||
|
||||
## Decision
|
||||
Use AirXDB to maintain `AirPlan/AGENTS.md`, C4 module docs, ADR records, and `AirPlan/docs/debug/gui-debug-log.md` during Midscene-based GUI debugging.
|
||||
|
||||
## Consequences
|
||||
- GUI issues can be reproduced with report evidence.
|
||||
- Long-term GUI automation choices become traceable.
|
||||
- AirDbg can consume AirXDB evidence for code-level fixes.
|
||||
|
||||
## Alternatives
|
||||
- Screenshot-only chat debugging: rejected because it is hard to resume and verify.
|
||||
"""
|
||||
|
||||
|
||||
def gui_debug_log_template() -> str:
|
||||
return """# GUI Debug Log
|
||||
|
||||
Append entries for AirXDB sessions.
|
||||
|
||||
## Entry Template
|
||||
|
||||
### YYYY-MM-DD: short GUI issue title
|
||||
|
||||
- Target surface: TODO
|
||||
- Midscene mode: Playwright / Bridge / Computer / MCP
|
||||
- Symptom: TODO
|
||||
- Expected: TODO
|
||||
- Actual: TODO
|
||||
- Reproduction: TODO
|
||||
- Report path: TODO
|
||||
- Key observations: TODO
|
||||
- Hand-off to AirDbg: TODO
|
||||
- Validation after fix: TODO
|
||||
- ADR/C4 updates: TODO
|
||||
- Residual risk: TODO
|
||||
"""
|
||||
|
||||
|
||||
def midscene_env_example() -> str:
|
||||
return """# AirXDB Midscene model configuration
|
||||
#
|
||||
# Copy this file to `.airxdb/midscene.local.env` and fill values locally,
|
||||
# or export the same variables in your shell before running AirXDB.
|
||||
# Never commit real API keys.
|
||||
|
||||
MIDSCENE_MODEL_NAME=
|
||||
MIDSCENE_MODEL_BASE_URL=
|
||||
MIDSCENE_MODEL_API_KEY=
|
||||
|
||||
# Required for semantic visual actions such as act, Tap, Input,
|
||||
# KeyboardPress, MouseMove with locate prompts, and aiLocate.
|
||||
# Common values:
|
||||
# - gpt-5 for GPT-5.x visual models, including gpt-5.4
|
||||
# - qwen2.5-vl
|
||||
# - qwen3-vl
|
||||
# - gemini
|
||||
# - doubao-seed
|
||||
MIDSCENE_MODEL_FAMILY=
|
||||
|
||||
# Optional:
|
||||
MCP_SERVER_REQUEST_TIMEOUT=120000
|
||||
"""
|
||||
|
||||
|
||||
def airxdb_gitignore_template() -> str:
|
||||
return """midscene.local.env
|
||||
"""
|
||||
|
||||
|
||||
def write_if_missing(path: Path, content: str) -> bool:
|
||||
if path.exists():
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
return True
|
||||
|
||||
|
||||
def upsert_agents_md(path: Path) -> str:
|
||||
block = airxdb_agents_block().rstrip() + "\n"
|
||||
|
||||
if path.exists():
|
||||
original = path.read_text(encoding="utf-8")
|
||||
existed = True
|
||||
else:
|
||||
original = "# AGENTS.md\n\n"
|
||||
existed = False
|
||||
|
||||
begin = original.find(MARKER_BEGIN)
|
||||
end = original.find(MARKER_END)
|
||||
|
||||
if begin >= 0 and end > begin:
|
||||
end += len(MARKER_END)
|
||||
updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip()
|
||||
status = "updated"
|
||||
else:
|
||||
updated = original.rstrip() + "\n\n" + block
|
||||
status = "updated" if existed else "created"
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(updated, encoding="utf-8", newline="\n")
|
||||
return status
|
||||
|
||||
|
||||
def artifact_map(project_root: Path) -> Dict[str, Path]:
|
||||
return {
|
||||
"AGENTS.md": project_root / "AirPlan" / "AGENTS.md",
|
||||
"c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md",
|
||||
"adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr",
|
||||
"adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airxdb-gui-debug-governance.md",
|
||||
"gui_debug_log": project_root / "AirPlan" / "docs" / "debug" / "gui-debug-log.md",
|
||||
"model_env_example": project_root / "AirPlan" / "state" / "airxdb" / "midscene.env.example",
|
||||
"airxdb_gitignore": project_root / "AirPlan" / "state" / "airxdb" / ".gitignore",
|
||||
"model_env_local": project_root / "AirPlan" / "state" / "airxdb" / "midscene.local.env",
|
||||
"state": project_root / "AirPlan" / "state" / "airxdb" / "state.json",
|
||||
}
|
||||
|
||||
|
||||
def load_local_midscene_env(project_root: Path) -> str:
|
||||
env_file = artifact_map(project_root)["model_env_local"]
|
||||
if not env_file.exists():
|
||||
return "missing"
|
||||
|
||||
loaded = 0
|
||||
for raw_line in env_file.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ and value:
|
||||
os.environ[key] = value
|
||||
loaded += 1
|
||||
return f"loaded:{loaded}"
|
||||
|
||||
|
||||
def infer_model_family(model_name: str) -> str:
|
||||
normalized = model_name.strip().lower()
|
||||
if not normalized:
|
||||
return ""
|
||||
if normalized.startswith("gpt-5"):
|
||||
return "gpt-5"
|
||||
if "qwen3" in normalized:
|
||||
return "qwen3-vl"
|
||||
if "qwen2.5" in normalized or "qwen-2.5" in normalized:
|
||||
return "qwen2.5-vl"
|
||||
if "gemini" in normalized:
|
||||
return "gemini"
|
||||
if "doubao" in normalized:
|
||||
return "doubao-seed"
|
||||
if "glm" in normalized:
|
||||
return "glm-v"
|
||||
return ""
|
||||
|
||||
|
||||
def status_from_missing(missing: list[str], invalid: bool = False) -> str:
|
||||
if invalid:
|
||||
return "invalid:MIDSCENE_MODEL_FAMILY"
|
||||
return "ok" if not missing else "missing:" + ",".join(missing)
|
||||
|
||||
|
||||
def midscene_config_health() -> Dict[str, object]:
|
||||
checked_names = SEMANTIC_MIDSCENE_ENV + OPTIONAL_MIDSCENE_ENV
|
||||
present = {name: bool(os.environ.get(name)) for name in checked_names}
|
||||
missing_basic = [name for name in REQUIRED_MIDSCENE_ENV if not present[name]]
|
||||
missing_semantic = [name for name in SEMANTIC_MIDSCENE_ENV if not present[name]]
|
||||
model_name = os.environ.get("MIDSCENE_MODEL_NAME", "").strip()
|
||||
model_family = os.environ.get("MIDSCENE_MODEL_FAMILY", "").strip()
|
||||
valid_model_family = (not model_family) or model_family in MODEL_FAMILY_VALUES
|
||||
suggested_model_family = infer_model_family(model_name)
|
||||
basic_ready = not missing_basic
|
||||
semantic_ready = basic_ready and not missing_semantic and valid_model_family
|
||||
|
||||
return {
|
||||
"ready": semantic_ready,
|
||||
"basicReady": basic_ready,
|
||||
"semanticReady": semantic_ready,
|
||||
"required": REQUIRED_MIDSCENE_ENV,
|
||||
"requiredForSemanticActions": SEMANTIC_MIDSCENE_ENV,
|
||||
"optional": OPTIONAL_MIDSCENE_ENV,
|
||||
"present": present,
|
||||
"missing": missing_basic,
|
||||
"missingForSemanticActions": missing_semantic,
|
||||
"validModelFamily": valid_model_family,
|
||||
"modelFamily": model_family,
|
||||
"suggestedModelFamily": suggested_model_family,
|
||||
"validModelFamilies": MODEL_FAMILY_VALUES,
|
||||
}
|
||||
|
||||
|
||||
def write_state(path: Path, enabled: bool, project_root: Path) -> None:
|
||||
artifacts = artifact_map(project_root)
|
||||
health = {
|
||||
name: artifacts[name].exists()
|
||||
for name in [
|
||||
"AGENTS.md",
|
||||
"c4_module",
|
||||
"adr_dir",
|
||||
"adr_0001",
|
||||
"gui_debug_log",
|
||||
"model_env_example",
|
||||
]
|
||||
}
|
||||
config_health = midscene_config_health()
|
||||
|
||||
payload = {
|
||||
"enabled": enabled,
|
||||
"updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root),
|
||||
"artifactHealth": health,
|
||||
"midsceneConfig": config_health,
|
||||
"screenshotCapture": {
|
||||
"requiresModelConfig": False,
|
||||
"recommendedScript": "airxdb_computer_mcp_smoke.py --action screenshot",
|
||||
},
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
results: Dict[str, str] = {}
|
||||
|
||||
results["midscene_local_env"] = load_local_midscene_env(project_root)
|
||||
results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"])
|
||||
results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists"
|
||||
artifacts["adr_dir"].mkdir(parents=True, exist_ok=True)
|
||||
results["adr_dir"] = "exists"
|
||||
results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists"
|
||||
results["gui_debug_log"] = "created" if write_if_missing(artifacts["gui_debug_log"], gui_debug_log_template()) else "exists"
|
||||
results["model_env_example"] = "created" if write_if_missing(artifacts["model_env_example"], midscene_env_example()) else "exists"
|
||||
results["airxdb_gitignore"] = "created" if write_if_missing(artifacts["airxdb_gitignore"], airxdb_gitignore_template()) else "exists"
|
||||
|
||||
config_health = midscene_config_health()
|
||||
results["midscene_basic_config"] = status_from_missing(config_health["missing"]) # type: ignore[arg-type]
|
||||
results["midscene_semantic_config"] = status_from_missing(
|
||||
config_health["missingForSemanticActions"], # type: ignore[arg-type]
|
||||
invalid=not bool(config_health["validModelFamily"]),
|
||||
)
|
||||
results["midscene_config"] = results["midscene_semantic_config"]
|
||||
|
||||
write_state(artifacts["state"], True, project_root)
|
||||
return "enabled", results
|
||||
|
||||
|
||||
def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
write_state(artifacts["state"], False, project_root)
|
||||
return "disabled", {}
|
||||
|
||||
|
||||
def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
local_env_status = load_local_midscene_env(project_root)
|
||||
state_file = artifacts["state"]
|
||||
|
||||
enabled = False
|
||||
if state_file.exists():
|
||||
try:
|
||||
payload = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
enabled = bool(payload.get("enabled"))
|
||||
except json.JSONDecodeError:
|
||||
enabled = False
|
||||
|
||||
results = {
|
||||
name: ("ok" if path.exists() else "missing")
|
||||
for name, path in artifacts.items()
|
||||
if name not in ["state", "model_env_local"]
|
||||
}
|
||||
results["midscene_local_env"] = local_env_status
|
||||
config_health = midscene_config_health()
|
||||
results["midscene_basic_config"] = status_from_missing(config_health["missing"]) # type: ignore[arg-type]
|
||||
results["midscene_semantic_config"] = status_from_missing(
|
||||
config_health["missingForSemanticActions"], # type: ignore[arg-type]
|
||||
invalid=not bool(config_health["validModelFamily"]),
|
||||
)
|
||||
results["midscene_config"] = results["midscene_semantic_config"]
|
||||
return ("enabled" if enabled else "disabled"), results
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Manage AirXDB GUI debug artifacts.")
|
||||
parser.add_argument("--mode", choices=["enter", "exit", "status"], default="enter")
|
||||
parser.add_argument("--project", default=".")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
|
||||
if args.mode == "enter":
|
||||
mode_state, result = enter_mode(project_root)
|
||||
elif args.mode == "exit":
|
||||
mode_state, result = exit_mode(project_root)
|
||||
else:
|
||||
mode_state, result = status_mode(project_root)
|
||||
|
||||
print(f"airxdb_mode={mode_state}")
|
||||
print(f"project_root={project_root}")
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
config_health = midscene_config_health()
|
||||
if mode_state == "enabled" and not config_health["semanticReady"]:
|
||||
missing = ",".join(config_health["missingForSemanticActions"]) # type: ignore[arg-type]
|
||||
print("midscene_config_required=true")
|
||||
print(f"missing_midscene_env={missing}")
|
||||
if not config_health["validModelFamily"]:
|
||||
print(f"invalid_midscene_model_family={config_health['modelFamily']}")
|
||||
if config_health["suggestedModelFamily"]:
|
||||
print(f"suggested_midscene_model_family={config_health['suggestedModelFamily']}")
|
||||
print("valid_midscene_model_families=" + ",".join(MODEL_FAMILY_VALUES))
|
||||
print("midscene_config_prompt=请先提供 Midscene 模型配置:MIDSCENE_MODEL_NAME、MIDSCENE_MODEL_BASE_URL、MIDSCENE_MODEL_API_KEY、MIDSCENE_MODEL_FAMILY;可选 MCP_SERVER_REQUEST_TIMEOUT。不要把真实 API key 写入 ADR/C4/debug log。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
128
AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_remote_device.py
Executable file
128
AirPlan/docs/spec/AirPlan-ParaV2/plugins/airxdb/scripts/airxdb_remote_device.py
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AirXDB remote GUI device helper over SSH."""
|
||||
from __future__ import annotations
|
||||
import argparse, base64, json, os, shlex, shutil, subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
def stamp(): return datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
def iso(): return datetime.now(timezone.utc).isoformat()
|
||||
def q(s): return shlex.quote(str(s))
|
||||
|
||||
def read_env(path):
|
||||
vals = {}
|
||||
if path.exists():
|
||||
for raw in path.read_text(encoding='utf-8-sig').splitlines():
|
||||
line = raw.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1); vals[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for k in ['AIRXDB_REMOTE_SSH_TARGET','AIRXDB_REMOTE_SSH_PORT','AIRXDB_REMOTE_SSH_OPTIONS','AIRXDB_REMOTE_WORKDIR','AIRXDB_REMOTE_SCREENSHOT_TOOL','AIRXDB_REMOTE_DISPLAY']:
|
||||
if os.environ.get(k): vals[k] = os.environ[k]
|
||||
return vals
|
||||
|
||||
def write_env(path, updates):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
old = path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
keys = set(updates); lines = []
|
||||
for raw in old.splitlines():
|
||||
key = raw.split('=',1)[0].strip() if '=' in raw and not raw.strip().startswith('#') else None
|
||||
if key not in keys: lines.append(raw)
|
||||
if lines and lines[-1].strip(): lines.append('')
|
||||
for k, v in updates.items():
|
||||
if v: lines.append(f'{k}={v}')
|
||||
path.write_text('\n'.join(lines).rstrip()+'\n', encoding='utf-8', newline='\n')
|
||||
|
||||
def ensure_files(project):
|
||||
d = project/'AirPlan'/'state'/'airxdb'; d.mkdir(parents=True, exist_ok=True)
|
||||
ex = d/'remote-device.env.example'
|
||||
if not ex.exists():
|
||||
ex.write_text('# AirXDB remote GUI device configuration\nAIRXDB_REMOTE_SSH_TARGET=user@host\nAIRXDB_REMOTE_SSH_PORT=22\nAIRXDB_REMOTE_SSH_OPTIONS=\nAIRXDB_REMOTE_WORKDIR=\nAIRXDB_REMOTE_SCREENSHOT_TOOL=auto\nAIRXDB_REMOTE_DISPLAY=\n', encoding='utf-8', newline='\n')
|
||||
gi = d/'.gitignore'; old = gi.read_text(encoding='utf-8') if gi.exists() else ''
|
||||
for item in ['remote-device.env','midscene.local.env']:
|
||||
if item not in old.splitlines(): old = (old.rstrip()+f'\n{item}\n').lstrip()
|
||||
gi.write_text(old, encoding='utf-8', newline='\n')
|
||||
|
||||
def ssh_args(env, cmd):
|
||||
target = env.get('AIRXDB_REMOTE_SSH_TARGET','')
|
||||
if not target: raise SystemExit('AIRXDB_REMOTE_SSH_TARGET is required')
|
||||
args = [shutil.which('ssh') or 'ssh']
|
||||
port = env.get('AIRXDB_REMOTE_SSH_PORT','22')
|
||||
if port: args += ['-p', port]
|
||||
opts = env.get('AIRXDB_REMOTE_SSH_OPTIONS','').strip()
|
||||
if opts: args += shlex.split(opts, posix=True)
|
||||
return args + [target, cmd]
|
||||
|
||||
def run(env, cmd, timeout=60):
|
||||
return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
||||
|
||||
def remote_home(env):
|
||||
r = run(env, 'printf %s "$HOME"', 15)
|
||||
return r.stdout.strip() if r.returncode == 0 else ''
|
||||
|
||||
def workdir(env):
|
||||
if env.get('AIRXDB_REMOTE_WORKDIR'): return env['AIRXDB_REMOTE_WORKDIR']
|
||||
home = remote_home(env); return (home.rstrip('/')+'/.airxdb') if home else '.airxdb'
|
||||
|
||||
def detect_tool(env):
|
||||
tool = env.get('AIRXDB_REMOTE_SCREENSHOT_TOOL','auto').strip()
|
||||
if tool and tool != 'auto':
|
||||
r = run(env, f'command -v {q(tool)}', 15); return tool if r.returncode == 0 else ''
|
||||
script = 'for t in gnome-screenshot spectacle scrot grim import screencapture; do command -v "$t" >/dev/null 2>&1 && { printf %s "$t"; exit 0; }; done'
|
||||
r = run(env, script, 15)
|
||||
return r.stdout.strip().splitlines()[-1] if r.returncode == 0 and r.stdout.strip() else ''
|
||||
|
||||
def install_tool(env):
|
||||
script = """set -e
|
||||
if command -v scrot >/dev/null 2>&1; then exit 0; fi
|
||||
if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y scrot;
|
||||
elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y scrot;
|
||||
elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y scrot;
|
||||
elif command -v apk >/dev/null 2>&1; then $SUDO apk add scrot;
|
||||
elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm scrot;
|
||||
else exit 42; fi"""
|
||||
return run(env, script, 300)
|
||||
|
||||
def setup(project, env, auto=True):
|
||||
if not env.get('AIRXDB_REMOTE_SSH_TARGET'):
|
||||
return {'status':'blocked','reason':'missing_remote_target','example':str(project/'AirPlan'/'state'/'airxdb'/'remote-device.env.example')}
|
||||
if not shutil.which('ssh'): return {'status':'blocked','reason':'ssh_not_found'}
|
||||
probe = run(env, 'printf ok', 20)
|
||||
if probe.returncode: return {'status':'blocked','reason':'ssh_probe_failed','stderr':probe.stderr.strip()}
|
||||
wd = workdir(env); mk = run(env, f'mkdir -p {q(wd)}', 20)
|
||||
if mk.returncode: return {'status':'blocked','reason':'remote_workdir_failed','stderr':mk.stderr.strip()}
|
||||
tool = detect_tool(env); install = 'skipped'
|
||||
if not tool and auto:
|
||||
inst = install_tool(env); install = 'ok' if inst.returncode == 0 else f'failed:{inst.returncode}'; tool = detect_tool(env)
|
||||
upd = {'AIRXDB_REMOTE_WORKDIR': wd}
|
||||
if tool: upd['AIRXDB_REMOTE_SCREENSHOT_TOOL'] = tool
|
||||
write_env(project/'AirPlan'/'state'/'airxdb'/'remote-device.env', upd)
|
||||
return {'status':'ok' if tool else 'blocked','target':env.get('AIRXDB_REMOTE_SSH_TARGET',''),'workdir':wd,'screenshotTool':tool,'autoConfigure':install,'hint':'' if tool else 'Install scrot/gnome-screenshot/grim/spectacle/import/screencapture or set AIRXDB_REMOTE_SCREENSHOT_TOOL.'}
|
||||
|
||||
def shot_cmd(tool, out, display):
|
||||
pre = f'export DISPLAY={q(display)}; ' if display else ''
|
||||
table = {'gnome-screenshot':f'gnome-screenshot -f {q(out)}','spectacle':f'spectacle -b -n -o {q(out)}','scrot':f'scrot {q(out)}','grim':f'grim {q(out)}','import':f'import -window root {q(out)}','screencapture':f'screencapture -x {q(out)}'}
|
||||
if tool not in table: raise SystemExit(f'unsupported screenshot tool: {tool}')
|
||||
return (pre if tool != 'screencapture' else '') + table[tool]
|
||||
|
||||
def emit(prefix, data):
|
||||
print(f'{prefix}_status={data.get("status","unknown")}')
|
||||
for k, v in data.items():
|
||||
if k != 'status': print(f'{k}={json.dumps(v, ensure_ascii=False) if isinstance(v,(dict,list)) else v}')
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('--project', default='.'); ap.add_argument('--action', choices=['setup','status','screenshot'], default='setup'); ap.add_argument('--output-dir', default=''); ap.add_argument('--timeout', type=int, default=60); ap.add_argument('--no-auto-configure', action='store_true'); a = ap.parse_args()
|
||||
project = Path(a.project).expanduser().resolve(); ensure_files(project); env = read_env(project/'AirPlan'/'state'/'airxdb'/'remote-device.env')
|
||||
info = setup(project, env, auto=(a.action!='status' and not a.no_auto_configure))
|
||||
if a.action in ['setup','status'] or info.get('status') != 'ok': emit('airxdb_remote', info); raise SystemExit(0 if info.get('status')=='ok' else 2)
|
||||
outdir = Path(a.output_dir).expanduser().resolve() if a.output_dir else project/'AirPlan'/'docs'/'debug'/'airxdb-artifacts'; outdir.mkdir(parents=True, exist_ok=True)
|
||||
remote = info['workdir'].rstrip('/') + '/' + stamp() + '-airxdb-remote.png'; cap = run(env, shot_cmd(info['screenshotTool'], remote, env.get('AIRXDB_REMOTE_DISPLAY','')), a.timeout)
|
||||
data = {'status':'ok' if cap.returncode == 0 else 'failed','target':info['target'],'tool':info['screenshotTool'],'remoteFile':remote,'stdout':cap.stdout.strip(),'stderr':cap.stderr.strip(),'capturedAt':iso()}
|
||||
if cap.returncode == 0:
|
||||
b64 = run(env, f'base64 < {q(remote)}', a.timeout)
|
||||
local = outdir/(stamp()+'-airxdb-remote.png'); local.write_bytes(base64.b64decode(''.join(b64.stdout.split()))); data['screenshot'] = str(local)
|
||||
report = outdir/(stamp()+'-airxdb-remote-device.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report'] = str(report)
|
||||
if data['status'] == 'ok':
|
||||
log = project/'AirPlan'/'docs'/'debug'/'gui-debug-log.md'; log.parent.mkdir(parents=True, exist_ok=True); log.open('a', encoding='utf-8', newline='\n').write(f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirXDB remote screenshot\n\n- Remote target: `{data['target']}`\n- Screenshot: `{data.get('screenshot','')}`\n- Report: `{data['report']}`\n- AirDbg handoff: TODO\n- Residual risk: remote screenshots may contain sensitive data.\n")
|
||||
emit('airxdb_remote', data); raise SystemExit(0 if data['status']=='ok' else 1)
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the AirXDB plugin into the current user's home-local plugin directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
PLUGIN_NAME = "airxdb"
|
||||
|
||||
|
||||
def copy_plugin(source: Path, target: Path) -> None:
|
||||
if source.resolve() == target.resolve():
|
||||
return
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git")
|
||||
shutil.copytree(source, target, ignore=ignore)
|
||||
|
||||
|
||||
def marketplace_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "local-airxdb",
|
||||
"interface": {"displayName": "Local AirXDB Plugins"},
|
||||
"plugins": [],
|
||||
}
|
||||
|
||||
|
||||
def update_marketplace(path: Path) -> None:
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload = marketplace_payload()
|
||||
|
||||
payload.setdefault("name", "local-airxdb")
|
||||
payload.setdefault("interface", {}).setdefault("displayName", "Local AirXDB Plugins")
|
||||
plugins = payload.setdefault("plugins", [])
|
||||
|
||||
entry = {
|
||||
"name": PLUGIN_NAME,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": f"./plugins/{PLUGIN_NAME}",
|
||||
},
|
||||
"policy": {
|
||||
"installation": "INSTALLED_BY_DEFAULT",
|
||||
"authentication": "ON_INSTALL",
|
||||
},
|
||||
"category": "Productivity",
|
||||
}
|
||||
|
||||
for index, existing in enumerate(plugins):
|
||||
if existing.get("name") == PLUGIN_NAME:
|
||||
plugins[index] = entry
|
||||
break
|
||||
else:
|
||||
plugins.append(entry)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Install AirXDB as a home-local Codex plugin.")
|
||||
parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1]))
|
||||
parser.add_argument("--home", default=str(Path.home()))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
home = Path(args.home).expanduser().resolve()
|
||||
target = home / "plugins" / PLUGIN_NAME
|
||||
marketplace = home / ".agents" / "plugins" / "marketplace.json"
|
||||
|
||||
copy_plugin(source, target)
|
||||
update_marketplace(marketplace)
|
||||
|
||||
print(f"installed_plugin={target}")
|
||||
print(f"marketplace={marketplace}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user