#!/bin/sh # Agent J emergency rescue (P332) — Linux · macOS · WSL. Works while Agent J's service and relay tunnel are down. # # curl -fsSL https://agentj.app/rescue | sh 复制这一行到终端 / paste this line into a terminal # curl -fsSL https://agentj.app/rescue | sh -s -- --en English only # curl -fsSL https://agentj.app/rescue | sh -s -- --ticket st_… read the reply to an earlier ticket # # Read-only checks → low-risk reversible repairs → a report redacted on this computer, sent over HTTPS to Agent J Father; # the ticket number it prints is what you pass on. Never reads or sends a key; no sudo; deletes nothing. # The Python part below is host/agentj/rescue_kit.py of the Agent J source (sha256 01f59ad7c401485f0437782fe448f16b42e79f87571747d05ad9542a6c5c68ff), inlined at build time. umask 077 cd "$HOME" 2>/dev/null || cd / # Python: Agent J's own interpreter first (it brings the same privacy filter `agentj support` uses), then a system one. # macOS: /usr/bin/python3 is only a stub until the Command Line Tools exist — calling it opens an install dialog. pick_python() { for c in "${AGENTJ_RESCUE_PYTHON:-}" "$HOME/.local/share/uv/tools/agentj/bin/python" "$HOME/.local/share/agentj/venv/bin/python"; do [ -n "$c" ] && [ -x "$c" ] && "$c" -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null && { echo "$c"; return 0; } done aj=$(command -v agentj 2>/dev/null || true) [ -z "$aj" ] && [ -x "$HOME/.local/bin/agentj" ] && aj="$HOME/.local/bin/agentj" if [ -n "$aj" ]; then shebang=$(head -n 1 "$aj" 2>/dev/null | sed -n 's/^#!\([^ ]*\).*/\1/p') [ -n "$shebang" ] && [ -x "$shebang" ] && "$shebang" -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null && { echo "$shebang"; return 0; } fi for c in python3 /usr/local/bin/python3 /opt/homebrew/bin/python3 /usr/bin/python3; do p=$(command -v "$c" 2>/dev/null || true) [ -n "$p" ] || continue if [ "$(uname -s)" = Darwin ] && [ "$p" = /usr/bin/python3 ] && ! xcode-select -p >/dev/null 2>&1; then continue; fi "$p" -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null && { echo "$p"; return 0; } done return 1 } PY=$(pick_python || true) if [ -z "$PY" ]; then # No usable Python: the fixed facts below carry no personal data, so they go as they are. echo "Agent J 紧急救援 / emergency rescue — 这台电脑没有可用的 Python,只做基本检查 / no usable Python: basic checks only" os=$(uname -sm 2>/dev/null) aj=no; { command -v agentj >/dev/null 2>&1 || [ -x "$HOME/.local/bin/agentj" ]; } && aj=yes svc=unknown if command -v systemctl >/dev/null 2>&1; then svc=$(systemctl --user is-active agentj.service 2>/dev/null || true); fi if [ "$(uname -s)" = Darwin ]; then launchctl print "gui/$(id -u)/net.agentj.host" >/dev/null 2>&1 && svc=loaded || svc=not-loaded; fi net=fail; curl -fsS -m 15 -o /dev/null https://agentj.app/robots.txt 2>/dev/null && net=ok echo " os=$os agentj=$aj service=$svc https=$net" [ "$net" = ok ] || { echo " ✗ 连不上 agentj.app:请拍下这个屏幕发给帮你的人 / cannot reach agentj.app: photograph this screen"; exit 4; } sid=$(curl -fsS -m 20 -X POST https://agentj.app/api/v1/install-sessions | sed -n 's/.*"install_session_id":"\(aji_[A-Za-z0-9_-]*\)".*/\1/p') [ -n "$sid" ] || { echo " ✗ 报告发不出去 / could not send the report"; exit 4; } body="AGENTJ-RESCUE v1 (no python)\\nos: $os\\nagentj: $aj\\nservice: $svc\\nhttps: $net" out=$(printf '{"kind":"bug","lang":"zh","install_md_version":"rescue-1","body":"%s"}' "$body" | curl -fsS -m 30 -H "authorization: Bearer $sid" -H 'content-type: application/json' --data-binary @- https://agentj.app/api/v1/support/messages) t=$(printf '%s' "$out" | sed -n 's/.*"thread":"\(st_[A-Za-z0-9_-]*\)".*/\1/p') [ -n "$t" ] && echo " ★ 工单号 / Ticket: $t — 请把它发给帮你的人 / send it to the person helping you" || { echo " ✗ 报告发不出去 / could not send"; exit 4; } exit 3 fi exec "$PY" -I - "$@" <<'AGENTJ_RESCUE_PY_EOF' """Agent J emergency rescue (P332): one command a non-technical helper runs when a computer's Agent J is offline. curl -fsSL https://agentj.app/rescue | sh (rescue.sh embeds this file; it never needs `agentj serve`) 1. diagnose read-only: system, agentj, service (systemd user / launchd, linger, ProcessType), relay reachability (DNS / TLS / proxy / clock), host.log summary, disk / memory / load, suspend and idle settings, `agentj doctor` 2. repair low-risk and reversible only, each step announced in plain words: linger, reset-failed / start / enable the service, re-install the service definition, `agentj doctor --reconnect` 3. report redacted on THIS machine (agentj.privacy when installed + the rules below), shown as a local copy, then sent over plain HTTPS to the Agent J Father support desk (install-session channel, FEEDBACK_API.md §Support) — works while the relay tunnel is down. The thread id is the ticket number the helper passes on. Never reads or sends a key, token or password; never deletes a file; never uses sudo; never kills a process. Stdlib only, Python ≥ 3.8 (macOS system python 3.9, Agent J's own interpreter, any distro python3). Exit: 0 healthy (after repairs) · 3 still broken, report sent · 4 still broken and the report could not be sent · 2 usage. """ from __future__ import annotations import argparse import getpass import json import os import pathlib import platform import re import shutil import socket import ssl import subprocess import sys import threading import time import urllib.error import urllib.request VERSION = "1" MARKER = "AGENTJ-RESCUE v" + VERSION # first line of every report: the support desk files it as label `rescue` API = os.environ.get("AGENTJ_RESCUE_API", "https://agentj.app/api").rstrip("/") SITE_HOST = "agentj.app" RELAY_HOST = os.environ.get("AGENTJ_RESCUE_RELAY_HOST", "relay.agentj.app") HOME = pathlib.Path.home() DIR = pathlib.Path(os.environ.get("AGENTJ_RESCUE_DIR") or HOME / ".agentj-rescue") MAX_BODY, MAX_ATTACH = 7800, 15800 # server: body ≤ 8000, attach ≤ 16000 INTERESTING = ("loop_stall", "relay_down", "relay_up", "serve_start", "serve_stop", "report_fail", "telegram_fail", "agent_read_failed", "bg_task_failed", "upgrade_wait", "seat_cert") OK, WARN, FAIL, INFO = "ok", "warn", "fail", "info" MARK = {OK: "✓", WARN: "!", FAIL: "✗", INFO: "·"} _THREAD = re.compile(r"st_[A-Za-z0-9_-]{22}") LANG = "zh" def T(zh: str, en: str) -> str: """One bilingual line: the helper's language first, the other after a slash (short).""" return zh if LANG == "zh-only" else en if LANG == "en" else f"{zh} / {en}" _QUIET = threading.local() # P332: help_flow / report_only inside serve print nothing (thread-local, never sys.stdout) def say(line: str = "") -> None: if not getattr(_QUIET, "on", False): print(line, flush=True) # ------------------------------------------------------------------ small runners (never raise, never a shell string) def run(argv, timeout=20, env=None): """(exit code, stdout+stderr text). 127 = not installed, 124 = timed out.""" try: p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env=env, stdin=subprocess.DEVNULL) return p.returncode, (p.stdout or "") + (p.stderr or "") except FileNotFoundError: return 127, "" except subprocess.TimeoutExpired as e: out = e.stdout.decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "") return 124, out except OSError as e: return 126, type(e).__name__ def has(cmd: str) -> bool: return shutil.which(cmd) is not None SERVICE_MANAGERS = ("systemctl", "loginctl", "launchctl") def foreign_manager(argv) -> bool: """The per-user service manager is per uid, not per HOME: a test or a simulation with a throw-away HOME (or AGENTJ_TEST=1) would otherwise start / enable units in the developer's real one (P332: a stand-in `agentj-p…` unit showed up in the real `agentj busy`). Then only a stand-in binary outside /usr and /bin may change it; the real one is refused.""" if not argv or argv[0] not in SERVICE_MANAGERS: return False try: import pwd real = os.path.realpath(pwd.getpwuid(os.getuid()).pw_dir) except (ImportError, KeyError): return False if os.environ.get("AGENTJ_TEST") != "1" and os.path.realpath(str(HOME)) == real: return False w = os.path.realpath(shutil.which(argv[0]) or "") return not w or w.startswith(("/usr/", "/bin/", "/sbin/")) def os_kind() -> str: s = platform.system() if s == "Darwin": return "macos" if s == "Linux": try: if "microsoft" in pathlib.Path("/proc/version").read_text().lower(): return "wsl" except OSError: pass return "linux" return s.lower() or "unknown" def service_name() -> str: n = os.environ.get("AGENTJ_SERVICE_NAME") if n and re.fullmatch(r"[A-Za-z0-9_.@-]{1,80}", n) and not n.endswith(".service"): return n return "net.agentj.host" if os_kind() == "macos" else "agentj" def state_dir() -> pathlib.Path: d = os.environ.get("AGENTJ_STATE_DIR") or os.environ.get("AGENTJARVIS_STATE_DIR") return pathlib.Path(d).expanduser() if d else HOME / ".local/state/agentj" def agentj_bin() -> str | None: for c in (os.environ.get("AGENTJ_RESCUE_AGENTJ"), shutil.which("agentj"), str(HOME / ".local/bin/agentj")): if c and os.path.isfile(c) and os.access(c, os.X_OK): return c return None # ------------------------------------------------------------------ redaction (layer 1 on this machine) _IPV4 = re.compile(r"(?,]{4,}") _BEARER = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}") _LONG = re.compile(r"(? _GENERIC_HOSTS = {"localhost", "archlinux", "arch", "ubuntu", "debian", "fedora", "linux", "macbook", "wsl"} _DISTRO = None def _distro_names() -> set: """ID / ID_LIKE / NAME words of /etc/os-release, lower-case (cached; empty on macOS).""" global _DISTRO if _DISTRO is None: names = set() try: for line in pathlib.Path("/etc/os-release").read_text().splitlines(): k, _, v = line.partition("=") if k in ("ID", "ID_LIKE", "NAME"): names |= {w.lower() for w in v.strip().strip('"').split() if len(w) >= 3} except OSError: pass _DISTRO = names return _DISTRO def _generic_host(name) -> bool: n = (name or "").lower() return n in _GENERIC_HOSTS or n in _distro_names() def _ip6(m) -> str: """Clock times (09:01:33) and plain numbers with colons are not IPv6: an address has a hex letter, '::' or ≥ 4 colons.""" t = m.group(0) if "::" in t or t.count(":") >= 4 or re.search(r"[A-Fa-f]", t): return "" return t def _entropic(tok: str) -> bool: if not (re.search(r"[A-Z]", tok) and re.search(r"[a-z]", tok) and re.search(r"\d", tok)) and not re.fullmatch(r"[0-9a-f]{32,}", tok): return False import math from collections import Counter n = len(tok) return -sum(c / n * math.log2(c / n) for c in Counter(tok).values()) >= 3.5 def redact(text: str) -> str: """Deterministic, conservative: credentials, emails, IPs, MACs, UUIDs, phone-like numbers, home paths, user / host names. Agent J's own privacy.redact runs first when the Agent J host has it (same rules as `agentj support`).""" user, host = _identity() try: from agentj import privacy as _p # type: ignore[import-not-found] text = _p.redact(text, host=None if _generic_host(host) else host, user=user, paths=True) except Exception: # noqa: BLE001 — not installed / older version: the local rules below still apply pass text = _URLCRED.sub(r"\1@", text) text = _QUERY.sub(r"\1", text) text = _BEARER.sub(r"\1 ", text) text = _PREFIXED.sub("", text) text = _KV.sub(lambda m: m.group(1) + m.group(2) + "", text) text = _EMAIL.sub("", text) ch = _channel() if ch: text = text.replace(ch, "") text = _CHANNEL_KW.sub(r"\1", text) text = _HANDLE.sub("", text) text = _MAC.sub("", text) text = _UUID.sub("", text) text = _ID.sub("", text) text = _IPV4.sub(lambda m: m.group(0) if m.group(0) in _KEEP_IP else "", text) text = _IPV6.sub(_ip6, text) text = _LONG.sub(lambda m: "" if _entropic(m.group(0)) else m.group(0), text) text = _PHONE.sub(lambda m: "" if len(re.sub(r"\D", "", m.group(0))) >= 10 and not re.fullmatch(r"\d{10}", m.group(0).strip()) else m.group(0), text) for p in sorted({str(HOME), os.path.realpath(str(HOME))}, key=len, reverse=True): if len(p) > 1: text = text.replace(p, "~") text = re.sub(r"(/(?:home|Users)/)[^/\s:]+", r"\1", text) for name, tag in ((user, ""), (host, "")): if name and len(name) >= 3 and not (tag == "" and _generic_host(name)): text = re.sub(r"(?i)(? str | None: """the Agent J host's relay channel id (state config.json), so no report carries it (P332 security review MEDIUM-3).""" try: ch = json.loads((state_dir() / "config.json").read_text()).get("channel") except (OSError, ValueError, AttributeError): return None return ch if isinstance(ch, str) and len(ch) >= 16 else None def _identity(): try: user = getpass.getuser() except Exception: # noqa: BLE001 user = os.environ.get("USER") host = socket.gethostname().split(".")[0] or None return user, host # ------------------------------------------------------------------ checks class Report: def __init__(self): self.checks = [] # (id, status, text) self.details = [] # (title, redacted-later text) self.facts = {} def add(self, cid: str, status: str, zh: str, en: str): self.checks.append((cid, status, T(zh, en))) say(f" {MARK[status]} {T(zh, en)}") def get(self, cid: str): for c in self.checks: if c[0] == cid: return c[1] return None def detail(self, title: str, text: str): if text and text.strip(): self.details.append((title, text.strip())) def check_system(r: Report): k = os_kind() r.facts["os_kind"] = k pretty = platform.platform() if k == "macos": _, v = run(["sw_vers", "-productVersion"], 5) pretty = f"macOS {v.strip()} {platform.machine()}" else: try: for line in pathlib.Path("/etc/os-release").read_text().splitlines(): if line.startswith("PRETTY_NAME="): pretty = line.split("=", 1)[1].strip('"') + f" {platform.machine()}" except OSError: pass r.facts.update(os=pretty) # PRETTY_NAME already names the distribution (and its desktop edition) r.add("os", INFO, f"系统:{r.facts['os']},Python {platform.python_version()}", f"system: {r.facts['os']}") up = None try: up = float(pathlib.Path("/proc/uptime").read_text().split()[0]) except (OSError, ValueError): rc, out = run(["sysctl", "-n", "kern.boottime"], 5) m = re.search(r"sec = (\d+)", out) if m: up = time.time() - int(m.group(1)) if up is not None: r.facts["uptime_h"] = round(up / 3600, 1) r.add("uptime", INFO, f"开机已 {up / 3600:.1f} 小时", f"up {up / 3600:.1f} h") try: l1, l5, _ = os.getloadavg() n = os.cpu_count() or 1 st = WARN if l5 > n * 2 else OK r.add("load", st, f"负载 {l1:.1f}/{l5:.1f}({n} 核){',偏高' if st == WARN else ''}", f"load {l1:.1f}/{l5:.1f} on {n} CPUs") except OSError: pass mem = _memory(k) if mem: total, avail = mem st = FAIL if avail < 200 * 2**20 else WARN if avail < 600 * 2**20 else OK r.add("memory", st, f"内存可用 {avail / 2**30:.1f} / {total / 2**30:.1f} GB", f"memory free {avail / 2**30:.1f} of {total / 2**30:.1f} GB") for label, path in (("home", HOME), ("tmp", pathlib.Path(os.environ.get("TMPDIR") or "/tmp"))): try: du = shutil.disk_usage(path) except OSError: continue free = du.free / 2**30 st = FAIL if free < 0.5 else WARN if free < 2 or du.free / du.total < 0.03 else OK r.add(f"disk_{label}", st, f"磁盘({'家目录' if label == 'home' else '临时目录'})剩 {free:.1f} GB" + (",快满了" if st != OK else ""), f"disk ({label}) free {free:.1f} GB") def _memory(k): try: info = dict(l.split(":", 1) for l in pathlib.Path("/proc/meminfo").read_text().splitlines() if ":" in l) kb = lambda key: int(info[key].split()[0]) * 1024 # noqa: E731 return kb("MemTotal"), kb("MemAvailable") except (OSError, KeyError, ValueError): pass if k == "macos": _, t = run(["sysctl", "-n", "hw.memsize"], 5) _, vm = run(["vm_stat"], 5) try: page = int(re.search(r"page size of (\d+)", vm).group(1)) free = sum(int(re.search(rf"{n}:\s+(\d+)", vm).group(1)) for n in ("Pages free", "Pages inactive", "Pages speculative")) return int(t.strip()), free * page except (AttributeError, ValueError): return None return None def check_sleep(r: Report): k = r.facts["os_kind"] if k == "linux": rc, out = run(["journalctl", "-b", "--since", "-24h", "-q", "--no-pager", "-o", "short-iso", "-g", "PM: suspend (entry|exit)|Suspending system|System returned from sleep|Lid closed"], 15) if rc in (0, 1) and rc != 127: n_sleep = len(re.findall(r"PM: suspend entry|Suspending system", out)) resumes = re.findall(r"^(\S+).*(?:PM: suspend exit|System returned from sleep)", out, re.M) r.facts["suspends_24h"] = n_sleep if n_sleep: r.add("suspend", WARN, f"过去 24 小时挂起过 {n_sleep} 次,最近一次醒来 {resumes[-1] if resumes else '未知'}——挂起期间手机必然离线", f"suspended {n_sleep}× in 24 h; the phone is offline while the Agent J host sleeps") else: r.add("suspend", OK, "过去 24 小时没有挂起", "no suspend in the last 24 h") r.detail("suspend / resume (system journal, 24 h)", "\n".join(_sleep_lines(out)[-12:])) else: r.add("suspend", INFO, "读不到系统日志里的挂起记录(权限)", "cannot read suspend records (permission)") conf = "" for p in [pathlib.Path("/etc/systemd/logind.conf"), *sorted(pathlib.Path("/etc/systemd/logind.conf.d").glob("*.conf"))]: try: conf += "\n".join(l for l in p.read_text().splitlines() if re.match(r"\s*(IdleAction|HandleLid\w*|HandleSuspend\w*)\s*=", l)) + "\n" except OSError: pass if conf.strip(): r.detail("logind.conf (non-default lines)", conf) if re.search(r"^\s*IdleAction\s*=\s*(suspend|hibernate|hybrid-sleep|suspend-then-hibernate)", conf, re.M): r.add("logind_idle", WARN, "系统设置了空闲自动挂起(logind IdleAction)", "logind suspends when idle (IdleAction)") hyp = HOME / ".config/hypr/hypridle.conf" if hyp.is_file(): txt = hyp.read_text(errors="replace") blocks = re.findall(r"listener\s*\{([^}]*)\}", txt) susp = [b for b in blocks if re.search(r"on-timeout\s*=.*(systemctl\s+suspend|suspend)", b) and not re.search(r"^\s*#", b)] secs = [int(m.group(1)) for b in susp for m in [re.search(r"timeout\s*=\s*(\d+)", b)] if m] if secs: r.add("idle_suspend", WARN, f"桌面空闲 {min(secs) // 60} 分钟后会自动挂起(hypridle)", f"the desktop suspends after {min(secs) // 60} idle minutes (hypridle)") else: r.add("idle_suspend", OK, "桌面空闲不会自动挂起(hypridle 没有 suspend)", "hypridle has no suspend step") r.detail("hypridle.conf listeners", "\n".join(_listener(b) for b in blocks)) rc, out = run(["systemd-inhibit", "--list", "--no-pager", "--no-legend"], 8) if rc == 0: # only the count and whether Agent J holds one: the list names other programs and users (MEDIUM-3) aj = any("agentj" in l.lower() for l in out.splitlines()) r.facts["inhibitors"] = len(out.strip().splitlines()) r.detail("sleep inhibitors", f"count {r.facts['inhibitors']} · agentj {'yes' if aj else 'no'}") if aj: r.add("inhibit", OK, "Agent J 正在阻止系统挂起", "Agent J holds a sleep inhibitor") sid = os.environ.get("XDG_SESSION_ID") if sid and re.fullmatch(r"[A-Za-z0-9_-]{1,32}", sid): # this session only; never the list of everyone's sessions rc, out = run(["loginctl", "show-session", sid, "-p", "IdleHint", "-p", "LockedHint", "-p", "State"], 5) if rc == 0: kv = dict(l.split("=", 1) for l in out.splitlines() if "=" in l) vals = [f"{k}={kv[k].strip()}" for k in ("IdleHint", "LockedHint", "State") if k in kv and re.fullmatch(r"[a-z-]{1,20}", kv[k].strip())] if vals: r.detail("logind session", " ".join(vals)) elif k == "macos": rc, out = run(["pmset", "-g"], 8) if rc == 0: # numeric settings only (the "(sleep prevented by …)" notes name other programs) r.detail("pmset -g", "\n".join(l.strip() for l in out.splitlines() if re.fullmatch(r"\s*[A-Za-z]+\s+\d+\s*", l))) m = re.search(r"^\s*sleep\s+(\d+)", out, re.M) if m and int(m.group(1)) > 0: r.add("idle_suspend", WARN, f"这台 Mac 空闲 {m.group(1)} 分钟后会睡眠", f"this Mac sleeps after {m.group(1)} idle minutes") elif m: r.add("idle_suspend", OK, "这台 Mac 不会因空闲睡眠", "this Mac never sleeps on idle") rc, out = run(["pmset", "-g", "assertions"], 8) if rc == 0 and re.search(r"agentj|caffeinate", out, re.I): r.add("inhibit", OK, "有程序在阻止睡眠(caffeinate / agentj)", "something holds a sleep assertion") rc, out = run(["sh", "-c", "pmset -g log | grep -E ' (Sleep|Wake|DarkWake) ' | tail -8"], 25) if rc == 0 and out.strip(): r.detail("pmset log (last sleep / wake)", "\n".join(_pmset_lines(out))) # P332 security review MEDIUM-3: raw command output is never a detail; each source is cut down to allowlisted fields _UNIT_PROPS = ("LoadState", "ActiveState", "SubState", "UnitFileState", "NRestarts", "Result", "ExecMainStatus", "ActiveEnterTimestamp") _SAFE = re.compile(r"[A-Za-z0-9_.:-]{1,40}") HOSTLOG_KEYS = ("ts", "ev", "code", "phase", "why", "status", "count", "ms", "result") _JOURNAL = re.compile(r"^(\S+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)$") _MANAGER_MSG = re.compile(r"(?i)\b(?:start|stop|fail|exit|restart|deactivat|result|status=|code=|kill|consumed|dump)") def _sleep_lines(out: str) -> list: """System journal suspend records → `