## New Skill: continue-claude-work (v1.1.0) - Recover actionable context from local `.claude` session artifacts - Compact-boundary-aware extraction (reads Claude's own compaction summaries) - Subagent workflow recovery (reports completed vs interrupted subagents) - Session end reason detection (clean exit, interrupted, error cascade, abandoned) - Size-adaptive strategy for small/large sessions - Noise filtering (skips 37-53% of session lines) - Self-session exclusion, stale index fallback, MEMORY.md integration - Bundled Python script (no external dependencies) - Security scan passed, argument-hint added ## Skill Updates - **skill-creator** (v1.5.0): Complete rewrite with evaluation framework - Added agents/ (analyzer, comparator, grader) - Added eval-viewer/ (generate_review.py, viewer.html) - Added scripts/ (run_eval, aggregate_benchmark, improve_description, run_loop) - Added references/schemas.md (eval/benchmark schemas) - Expanded SKILL.md with inline vs fork guidance, progressive disclosure patterns - Enhanced package_skill.py and quick_validate.py - **transcript-fixer** (v1.2.0): CLI improvements and test coverage - Enhanced argument_parser.py and commands.py - Added correction_service.py improvements - Added test_correction_service.py - **tunnel-doctor** (v1.4.0): Quick diagnostic script - Added scripts/quick_diagnose.py - Enhanced SKILL.md with 5-layer conflict model - **pdf-creator** (v1.1.0): Auto DYLD_LIBRARY_PATH + rendering fixes - Auto-detect and set DYLD_LIBRARY_PATH for weasyprint - Fixed list rendering and CSS improvements - **github-contributor** (v1.0.3): Enhanced project evaluation - Added evidence-loop, redaction, and merge-ready PR guidance ## Documentation - Updated marketplace.json (v1.38.0, 42 skills) - Updated CHANGELOG.md with v1.38.0 entry - Updated CLAUDE.md (skill count, marketplace version, #42 description) - Updated README.md (badges, skill section #42, use case, requirements) - Updated README.zh-CN.md (badges, skill section #42, use case, requirements) - Fixed absolute paths in continue-claude-work/references/file_structure.md ## Validation - All skills passed quick_validate.py - continue-claude-work passed security_scan.py - marketplace.json validated (valid JSON) - Cross-checked version consistency across all docs
449 lines
14 KiB
Python
Executable File
449 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Quick tunnel/proxy conflict diagnostics for macOS.
|
|
|
|
This script detects the most common local and Tailscale networking conflicts:
|
|
1) Shell proxy env + NO_PROXY mismatch
|
|
2) System proxy exceptions mismatch
|
|
3) Proxy path failure vs direct path success
|
|
4) Local TLS trust issues
|
|
5) Route ownership conflicts for a Tailscale IP (optional)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
|
|
def run(cmd: List[str], env: Optional[Dict[str, str]] = None) -> Tuple[int, str, str]:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
|
|
|
|
def split_csv(value: str) -> List[str]:
|
|
if not value:
|
|
return []
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
def match_proxy_pattern(host: str, pattern: str) -> bool:
|
|
p = pattern.strip().lower()
|
|
h = host.strip().lower()
|
|
if not p or not h:
|
|
return False
|
|
|
|
# CIDR / wildcard IP patterns are not domain checks.
|
|
if "/" in p and not p.startswith("*."):
|
|
return False
|
|
if re.match(r"^\d+\.\d+\.\d+\.\*$", p):
|
|
return False
|
|
|
|
if p == h:
|
|
return True
|
|
if p.startswith("*."):
|
|
suffix = p[1:] # keep leading dot
|
|
return h.endswith(suffix)
|
|
if p.startswith("."):
|
|
return h.endswith(p)
|
|
return False
|
|
|
|
|
|
def has_host_bypass(host: str, patterns: List[str]) -> bool:
|
|
return any(match_proxy_pattern(host, item) for item in patterns)
|
|
|
|
|
|
def parse_scutil_proxy() -> Dict[str, object]:
|
|
code, stdout, _stderr = run(["scutil", "--proxy"])
|
|
if code != 0:
|
|
return {"raw": "", "exceptions": [], "http_enabled": False, "https_enabled": False}
|
|
|
|
raw = stdout
|
|
exceptions: List[str] = []
|
|
for line in raw.splitlines():
|
|
m = re.search(r"\d+\s*:\s*(.+)$", line)
|
|
if m and "ExceptionsList" not in line:
|
|
exceptions.append(m.group(1).strip())
|
|
|
|
http_enabled = bool(re.search(r"HTTPEnable\s*:\s*1", raw))
|
|
https_enabled = bool(re.search(r"HTTPSEnable\s*:\s*1", raw))
|
|
|
|
return {
|
|
"raw": raw,
|
|
"exceptions": exceptions,
|
|
"http_enabled": http_enabled,
|
|
"https_enabled": https_enabled,
|
|
}
|
|
|
|
|
|
def resolve_host(host: str) -> List[str]:
|
|
ips = []
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
for item in infos:
|
|
ip = item[4][0]
|
|
if ip not in ips:
|
|
ips.append(ip)
|
|
except socket.gaierror:
|
|
pass
|
|
return ips
|
|
|
|
|
|
def curl_status(url: str, timeout: int, mode: str, proxy_url: Optional[str] = None) -> Dict[str, object]:
|
|
cmd = [
|
|
"curl",
|
|
"-k",
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"--max-time",
|
|
str(timeout),
|
|
url,
|
|
]
|
|
|
|
env = os.environ.copy()
|
|
if mode == "direct":
|
|
for key in (
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"all_proxy",
|
|
"ALL_PROXY",
|
|
):
|
|
env.pop(key, None)
|
|
elif mode == "forced_proxy" and proxy_url:
|
|
cmd.extend(["--proxy", proxy_url])
|
|
|
|
code, stdout, stderr = run(cmd, env=env)
|
|
http_code = stdout if stdout else "000"
|
|
ok = code == 0 and http_code.isdigit() and http_code != "000"
|
|
|
|
return {
|
|
"ok": ok,
|
|
"http_code": http_code,
|
|
"exit_code": code,
|
|
"stderr": stderr,
|
|
"command": " ".join(shlex.quote(x) for x in cmd),
|
|
}
|
|
|
|
|
|
def strict_tls_check(url: str, timeout: int) -> Dict[str, object]:
|
|
cmd = [
|
|
"curl",
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"--max-time",
|
|
str(timeout),
|
|
url,
|
|
]
|
|
env = os.environ.copy()
|
|
for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
|
|
env.pop(key, None)
|
|
code, stdout, stderr = run(cmd, env=env)
|
|
cert_issue = "certificate" in stderr.lower() or "ssl" in stderr.lower()
|
|
return {
|
|
"ok": code == 0 and stdout != "000",
|
|
"http_code": stdout if stdout else "000",
|
|
"exit_code": code,
|
|
"stderr": stderr,
|
|
"cert_issue": cert_issue,
|
|
}
|
|
|
|
|
|
def route_check(tailscale_ip: str) -> Dict[str, object]:
|
|
code, stdout, stderr = run(["route", "-n", "get", tailscale_ip])
|
|
if code != 0:
|
|
return {"ok": False, "interface": "", "gateway": "", "raw": stderr or stdout}
|
|
|
|
interface = ""
|
|
gateway = ""
|
|
for line in stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("interface:"):
|
|
interface = line.split(":", 1)[1].strip()
|
|
if line.startswith("gateway:"):
|
|
gateway = line.split(":", 1)[1].strip()
|
|
|
|
return {
|
|
"ok": True,
|
|
"interface": interface,
|
|
"gateway": gateway,
|
|
"raw": stdout,
|
|
}
|
|
|
|
|
|
def pick_proxy_url() -> Optional[str]:
|
|
for key in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"):
|
|
value = os.environ.get(key)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def build_report(
|
|
host: str,
|
|
url: str,
|
|
timeout: int,
|
|
tailscale_ip: Optional[str],
|
|
) -> Dict[str, object]:
|
|
no_proxy = os.environ.get("NO_PROXY", "")
|
|
no_proxy_lc = os.environ.get("no_proxy", "")
|
|
no_proxy_entries = split_csv(no_proxy if no_proxy else no_proxy_lc)
|
|
|
|
scutil_info = parse_scutil_proxy()
|
|
scutil_exceptions = scutil_info["exceptions"]
|
|
|
|
proxy_url = pick_proxy_url()
|
|
direct = curl_status(url, timeout, mode="direct")
|
|
ambient = curl_status(url, timeout, mode="ambient")
|
|
forced_proxy = curl_status(url, timeout, mode="forced_proxy", proxy_url=proxy_url) if proxy_url else None
|
|
strict_tls = strict_tls_check(url, timeout) if url.startswith("https://") else None
|
|
|
|
host_ips = resolve_host(host)
|
|
host_in_no_proxy = has_host_bypass(host, no_proxy_entries)
|
|
host_in_scutil_exceptions = has_host_bypass(host, scutil_exceptions)
|
|
|
|
findings: List[Dict[str, str]] = []
|
|
|
|
if not host_ips:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Host resolution failed",
|
|
"detail": f"{host} could not be resolved. Check DNS/hosts first.",
|
|
"fix": f"Add a hosts entry if this is local: 127.0.0.1 {host}",
|
|
}
|
|
)
|
|
|
|
if proxy_url and not host_in_no_proxy:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "NO_PROXY missing target host",
|
|
"detail": f"Proxy is enabled ({proxy_url}) but NO_PROXY does not match {host}.",
|
|
"fix": (
|
|
"Add host to NO_PROXY/no_proxy, e.g. "
|
|
f"NO_PROXY=...,{host}"
|
|
),
|
|
}
|
|
)
|
|
|
|
if (scutil_info["http_enabled"] or scutil_info["https_enabled"]) and not host_in_scutil_exceptions:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "System proxy exception missing target host",
|
|
"detail": f"scutil active exceptions do not include {host}.",
|
|
"fix": (
|
|
"Add host to proxy app skip/bypass list (Shadowrocket/Clash/Surge), "
|
|
"then reload profile."
|
|
),
|
|
}
|
|
)
|
|
|
|
if direct["ok"] and forced_proxy and not forced_proxy["ok"]:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Proxy path is broken for target host",
|
|
"detail": (
|
|
"Direct access works, but forced proxy tunnel fails. "
|
|
"Traffic must bypass proxy for this host."
|
|
),
|
|
"fix": (
|
|
f"Add {host} to both NO_PROXY and proxy app skip-proxy/DIRECT rules."
|
|
),
|
|
}
|
|
)
|
|
|
|
if not ambient["ok"] and direct["ok"]:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Ambient shell path fails while direct path works",
|
|
"detail": (
|
|
"Current shell env/proxy settings break default access."
|
|
),
|
|
"fix": (
|
|
"Use NO_PROXY for this host, or temporarily unset proxy env for local verification."
|
|
),
|
|
}
|
|
)
|
|
|
|
if strict_tls and not strict_tls["ok"] and direct["ok"] and strict_tls["cert_issue"]:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "TLS trust issue detected",
|
|
"detail": "Network path is reachable, but strict TLS validation failed.",
|
|
"fix": "Trust local CA certificate (for local/internal TLS) or use a valid public cert.",
|
|
}
|
|
)
|
|
|
|
route_info = route_check(tailscale_ip) if tailscale_ip else None
|
|
if route_info and route_info["ok"]:
|
|
iface = str(route_info["interface"])
|
|
if iface.startswith("en"):
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Possible route hijack for Tailscale destination",
|
|
"detail": f"route -n get {tailscale_ip} resolved to {iface}, not utun*.",
|
|
"fix": (
|
|
"Check proxy TUN excluded-routes. Do not exclude 100.64.0.0/10 from TUN route table."
|
|
),
|
|
}
|
|
)
|
|
|
|
summary = {
|
|
"host": host,
|
|
"url": url,
|
|
"host_ips": host_ips,
|
|
"proxy_url": proxy_url or "",
|
|
"env_no_proxy": no_proxy if no_proxy else no_proxy_lc,
|
|
"host_in_no_proxy": host_in_no_proxy,
|
|
"scutil_http_enabled": scutil_info["http_enabled"],
|
|
"scutil_https_enabled": scutil_info["https_enabled"],
|
|
"host_in_scutil_exceptions": host_in_scutil_exceptions,
|
|
"connectivity": {
|
|
"ambient": ambient,
|
|
"direct": direct,
|
|
"forced_proxy": forced_proxy,
|
|
"strict_tls": strict_tls,
|
|
},
|
|
"tailscale_route": route_info,
|
|
"findings": findings,
|
|
}
|
|
return summary
|
|
|
|
|
|
def print_human(report: Dict[str, object]) -> int:
|
|
print("=== Tunnel Doctor Quick Diagnose ===")
|
|
print(f"Host: {report['host']}")
|
|
print(f"URL: {report['url']}")
|
|
ips = report["host_ips"]
|
|
print(f"Resolved IPs: {', '.join(ips) if ips else 'N/A'}")
|
|
print("")
|
|
|
|
print("Proxy Context")
|
|
print(f"- proxy env: {report['proxy_url'] or '(not set)'}")
|
|
print(f"- host in NO_PROXY: {'yes' if report['host_in_no_proxy'] else 'no'}")
|
|
print(
|
|
"- system proxy enabled: "
|
|
f"HTTP={'yes' if report['scutil_http_enabled'] else 'no'} "
|
|
f"HTTPS={'yes' if report['scutil_https_enabled'] else 'no'}"
|
|
)
|
|
print(f"- host in scutil exceptions: {'yes' if report['host_in_scutil_exceptions'] else 'no'}")
|
|
print("")
|
|
|
|
conn = report["connectivity"]
|
|
print("Connectivity Checks")
|
|
for key in ("ambient", "direct", "forced_proxy", "strict_tls"):
|
|
value = conn.get(key)
|
|
if not value:
|
|
continue
|
|
ok = "PASS" if value.get("ok") else "FAIL"
|
|
print(
|
|
f"- {key:12s}: {ok} "
|
|
f"(http={value.get('http_code', '000')}, exit={value.get('exit_code', 'n/a')})"
|
|
)
|
|
stderr = value.get("stderr")
|
|
if stderr:
|
|
print(f" stderr: {stderr}")
|
|
print("")
|
|
|
|
route = report.get("tailscale_route")
|
|
if route:
|
|
if route.get("ok"):
|
|
print("Tailscale Route Check")
|
|
print(f"- interface: {route.get('interface') or 'N/A'}")
|
|
print(f"- gateway: {route.get('gateway') or 'N/A'}")
|
|
print("")
|
|
else:
|
|
print("Tailscale Route Check")
|
|
print(f"- failed: {route.get('raw', '')}")
|
|
print("")
|
|
|
|
findings = report["findings"]
|
|
if not findings:
|
|
print("Result: no high-confidence conflict found.")
|
|
print("If browser still fails, verify proxy app profile mode/rule order and reload profile.")
|
|
return 0
|
|
|
|
print("Findings")
|
|
severity_order = {"error": 0, "warn": 1, "info": 2}
|
|
findings_sorted = sorted(findings, key=lambda x: severity_order.get(str(x.get("level")), 99))
|
|
for idx, item in enumerate(findings_sorted, start=1):
|
|
print(f"{idx}. [{item['level'].upper()}] {item['title']}")
|
|
print(f" Detail: {item['detail']}")
|
|
print(f" Fix: {item['fix']}")
|
|
|
|
return 1 if any(x["level"] == "error" for x in findings) else 0
|
|
|
|
|
|
def main() -> int:
|
|
if sys.platform != "darwin":
|
|
print("This script is designed for macOS only.", file=sys.stderr)
|
|
return 2
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Quick diagnostics for Tailscale + proxy conflicts on macOS."
|
|
)
|
|
parser.add_argument("--host", default="local.claude4.dev", help="Target host to diagnose.")
|
|
parser.add_argument(
|
|
"--url",
|
|
default="",
|
|
help="Full URL to test. Default: https://<host>/health",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=8,
|
|
help="curl timeout seconds (default: 8).",
|
|
)
|
|
parser.add_argument(
|
|
"--tailscale-ip",
|
|
default="",
|
|
help="Optional Tailscale IP for route ownership check (e.g. 100.101.102.103).",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output JSON report.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
url = args.url.strip() or f"https://{args.host}/health"
|
|
report = build_report(
|
|
host=args.host.strip(),
|
|
url=url,
|
|
timeout=max(args.timeout, 1),
|
|
tailscale_ip=args.tailscale_ip.strip() or None,
|
|
)
|
|
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
return 0
|
|
return print_human(report)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|