322 lines
11 KiB
Python
Executable File
322 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read-only, bounded CodexBar CLI bridge."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Sequence
|
|
|
|
|
|
MAX_CAPTURE_BYTES = 1024 * 1024
|
|
DEFAULT_TIMEOUT = 120.0
|
|
BIN_ENV = "CODEXBAR_BIN"
|
|
TIMEOUT_ENV = "CODEXBAR_TIMEOUT"
|
|
SKIP_DISCOVERY_ENV = "CODEXBAR_SKIP_DISCOVERY"
|
|
|
|
SECRET = "<redacted:secret>"
|
|
IDENTITY = "<redacted:identity>"
|
|
EMAIL = "<redacted:email>"
|
|
|
|
EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", re.I)
|
|
BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Z0-9._~+/=\-]+")
|
|
JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")
|
|
LABELED_SECRET_RE = re.compile(
|
|
r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
|
r"session(?:id)?|secret|password|passwd|cookie|set-cookie)(\s*[:=]\s*)([^\s,;]+)"
|
|
)
|
|
WORD_SECRET_RE = re.compile(
|
|
r"(?i)\b(token|secret|password|passwd|cookie)\s+([A-Z0-9._~+/=\-]{6,})\b"
|
|
)
|
|
SECRET_KEY_RE = re.compile(
|
|
r"(?i)(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
|
r"session(?:id)?|secret|password|passwd|cookie)"
|
|
)
|
|
IDENTITY_KEYS = {
|
|
"accountemail",
|
|
"accountorganization",
|
|
"accountid",
|
|
"accountname",
|
|
"email",
|
|
"organization",
|
|
"userid",
|
|
"username",
|
|
}
|
|
# ProviderIdentitySnapshot.providerID is UsageProvider, not an account identifier.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Binary:
|
|
path: str
|
|
source: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Result:
|
|
returncode: int
|
|
stdout: bytes
|
|
stderr: bytes
|
|
timed_out: bool
|
|
|
|
|
|
def normalized_key(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]", "", value.lower())
|
|
|
|
|
|
def timeout_seconds() -> float:
|
|
try:
|
|
return max(1.0, float(os.environ.get(TIMEOUT_ENV, DEFAULT_TIMEOUT)))
|
|
except ValueError:
|
|
return DEFAULT_TIMEOUT
|
|
|
|
|
|
def safe_path(path: str) -> str:
|
|
home = str(Path.home())
|
|
if path == home:
|
|
return "~"
|
|
if path.startswith(home + os.sep):
|
|
return "~" + path[len(home) :]
|
|
return re.sub(r"^/Users/[^/]+", "/Users/<redacted>", path)
|
|
|
|
|
|
def candidates() -> list[tuple[str, Path]]:
|
|
found: list[tuple[str, Path]] = []
|
|
override = os.environ.get(BIN_ENV)
|
|
if override:
|
|
found.append(("env", Path(override).expanduser()))
|
|
|
|
path_hit = shutil.which("codexbar")
|
|
if path_hit:
|
|
found.append(("path", Path(path_hit)))
|
|
|
|
if os.environ.get(SKIP_DISCOVERY_ENV) == "1":
|
|
return found
|
|
|
|
home = Path.home()
|
|
found.extend(
|
|
[
|
|
("homebrew", Path("/opt/homebrew/bin/codexbar")),
|
|
("homebrew", Path("/usr/local/bin/codexbar")),
|
|
("app", Path("/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI")),
|
|
("app", home / "Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"),
|
|
]
|
|
)
|
|
for root in (Path("/opt/homebrew/Caskroom/codexbar"), Path("/usr/local/Caskroom/codexbar")):
|
|
found.extend(("cask", app / "Contents/Helpers/CodexBarCLI") for app in sorted(root.glob("*/CodexBar.app")))
|
|
return found
|
|
|
|
|
|
def resolve_binary() -> Binary | None:
|
|
self_path = Path(__file__).resolve()
|
|
seen: set[Path] = set()
|
|
for source, candidate in candidates():
|
|
try:
|
|
resolved = candidate.resolve()
|
|
if resolved in seen or resolved == self_path:
|
|
continue
|
|
seen.add(resolved)
|
|
if resolved.is_file() and os.access(resolved, os.X_OK):
|
|
return Binary(str(resolved), source)
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def drain(pipe: Any, target: bytearray) -> None:
|
|
try:
|
|
while True:
|
|
chunk = pipe.read(65536)
|
|
if not chunk:
|
|
break
|
|
room = MAX_CAPTURE_BYTES - len(target)
|
|
if room > 0:
|
|
target.extend(chunk[:room])
|
|
finally:
|
|
pipe.close()
|
|
|
|
|
|
def stop_group(process: subprocess.Popen[bytes], sig: signal.Signals) -> None:
|
|
try:
|
|
os.killpg(process.pid, sig)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def run_process(argv: Sequence[str]) -> Result:
|
|
process = subprocess.Popen(
|
|
list(argv),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True,
|
|
)
|
|
assert process.stdout is not None
|
|
assert process.stderr is not None
|
|
stdout = bytearray()
|
|
stderr = bytearray()
|
|
readers = [
|
|
threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
|
|
threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
|
|
]
|
|
for reader in readers:
|
|
reader.start()
|
|
|
|
timed_out = False
|
|
try:
|
|
process.wait(timeout=timeout_seconds())
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
stop_group(process, signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
stop_group(process, signal.SIGKILL)
|
|
process.wait()
|
|
|
|
for reader in readers:
|
|
reader.join(timeout=3)
|
|
return Result(124 if timed_out else process.returncode, bytes(stdout), bytes(stderr), timed_out)
|
|
|
|
|
|
def decode(value: bytes) -> str:
|
|
return value.decode("utf-8", errors="replace")
|
|
|
|
|
|
def sanitize_text(value: str, include_identities: bool) -> str:
|
|
value = JWT_RE.sub(SECRET, value)
|
|
value = BEARER_RE.sub("Bearer " + SECRET, value)
|
|
value = LABELED_SECRET_RE.sub(lambda match: match.group(1) + match.group(2) + SECRET, value)
|
|
value = WORD_SECRET_RE.sub(lambda match: match.group(1) + " " + SECRET, value)
|
|
if not include_identities:
|
|
value = EMAIL_RE.sub(EMAIL, value)
|
|
return value
|
|
|
|
|
|
def sanitize(value: Any, include_identities: bool, key: str | None = None) -> Any:
|
|
if key and SECRET_KEY_RE.search(key):
|
|
return SECRET
|
|
if key and normalized_key(key) in IDENTITY_KEYS and not include_identities and value is not None:
|
|
return EMAIL if "email" in normalized_key(key) else IDENTITY
|
|
if isinstance(value, dict):
|
|
return {str(child_key): sanitize(child, include_identities, str(child_key)) for child_key, child in value.items()}
|
|
if isinstance(value, list):
|
|
return [sanitize(child, include_identities) for child in value]
|
|
if isinstance(value, str):
|
|
return sanitize_text(value, include_identities)
|
|
return value
|
|
|
|
|
|
def print_json(value: Any) -> None:
|
|
print(json.dumps(value, indent=2, sort_keys=True))
|
|
|
|
|
|
def print_error(kind: str, message: str, *, binary: Binary | None = None) -> None:
|
|
payload: dict[str, Any] = {"error": {"kind": kind, "message": sanitize_text(message, False)}}
|
|
if binary:
|
|
payload["binary"] = {"path": safe_path(binary.path), "source": binary.source}
|
|
print_json(payload)
|
|
|
|
|
|
def emit_stderr(result: Result, include_identities: bool) -> None:
|
|
text = sanitize_text(decode(result.stderr), include_identities).strip()
|
|
if text:
|
|
print(text, file=sys.stderr)
|
|
|
|
|
|
def read_json(binary: Binary, argv: Sequence[str], include_identities: bool) -> int:
|
|
result = run_process([binary.path, *argv])
|
|
emit_stderr(result, include_identities)
|
|
if result.timed_out:
|
|
print_error("timeout", f"CodexBar exceeded {timeout_seconds():g}s and was stopped.", binary=binary)
|
|
return 124
|
|
try:
|
|
payload = json.loads(decode(result.stdout))
|
|
except json.JSONDecodeError as error:
|
|
preview = sanitize_text(decode(result.stdout[:400]), False)
|
|
print_error("invalid_json", f"CodexBar JSON failed: {error.msg}. stdout={preview!r}", binary=binary)
|
|
return result.returncode or 1
|
|
print_json(sanitize(payload, include_identities))
|
|
return result.returncode
|
|
|
|
|
|
def doctor(binary: Binary, include_identities: bool) -> int:
|
|
version = run_process([binary.path, "--version"])
|
|
if version.timed_out:
|
|
print_error("timeout", f"CodexBar version exceeded {timeout_seconds():g}s.", binary=binary)
|
|
return 124
|
|
validation = run_process([binary.path, "config", "validate", "--format", "json", "--json-only"])
|
|
emit_stderr(version, include_identities)
|
|
emit_stderr(validation, include_identities)
|
|
if validation.timed_out:
|
|
print_error("timeout", f"CodexBar config validation exceeded {timeout_seconds():g}s.", binary=binary)
|
|
return 124
|
|
try:
|
|
issues = json.loads(decode(validation.stdout))
|
|
except json.JSONDecodeError as error:
|
|
print_error("invalid_json", f"CodexBar config validation failed: {error.msg}.", binary=binary)
|
|
return validation.returncode or 1
|
|
print_json(
|
|
{
|
|
"binary": {"path": safe_path(binary.path), "source": binary.source},
|
|
"configIssues": sanitize(issues, include_identities),
|
|
"version": sanitize_text((decode(version.stdout) or decode(version.stderr)).strip(), include_identities),
|
|
}
|
|
)
|
|
return version.returncode or validation.returncode
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
root = argparse.ArgumentParser(prog="codexbar", description="CodexBar read. JSON out. No writes.")
|
|
commands = root.add_subparsers(dest="command", required=True)
|
|
for name in ("doctor", "providers"):
|
|
command = commands.add_parser(name)
|
|
command.add_argument("--include-identities", action="store_true")
|
|
usage = commands.add_parser("usage")
|
|
scope = usage.add_mutually_exclusive_group()
|
|
scope.add_argument("--all", action="store_true")
|
|
scope.add_argument("--provider")
|
|
usage.add_argument("--include-identities", action="store_true")
|
|
return root
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = parser().parse_args(argv)
|
|
binary = resolve_binary()
|
|
if not binary:
|
|
print_error("missing", "CodexBar CLI not found. Install CLI in CodexBar Advanced settings or set CODEXBAR_BIN.")
|
|
return 1
|
|
try:
|
|
if args.command == "doctor":
|
|
return doctor(binary, args.include_identities)
|
|
if args.command == "providers":
|
|
return read_json(
|
|
binary,
|
|
["config", "providers", "--format", "json", "--json-only"],
|
|
args.include_identities,
|
|
)
|
|
usage_args = ["usage", "--format", "json", "--json-only"]
|
|
if args.all:
|
|
usage_args.extend(["--provider", "all"])
|
|
elif args.provider:
|
|
usage_args.extend(["--provider", args.provider])
|
|
return read_json(binary, usage_args, args.include_identities)
|
|
except OSError as error:
|
|
print_error("launch", str(error), binary=binary)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except KeyboardInterrupt:
|
|
print_error("interrupted", "Interrupted.")
|
|
raise SystemExit(130)
|