#!/usr/bin/env python3
"""Approval-gated one-file launcher for local Claude Code and Codex CLIs."""

from __future__ import annotations

import argparse
import base64
import contextlib
import datetime
import getpass
import hashlib
import json
import os
import re
import secrets
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional


VERSION = "0.4.0"
# Renew slightly before expiry so an in-flight Claude Code request never races
# the gateway clock. Claude Code also re-invokes the helper on any upstream 401.
TOKEN_RENEW_MARGIN_SECONDS = 60
DEFAULT_SERVER = os.environ.get("REMOTECC_URL", "https://gateway.example.com")
CONTROL_TIMEOUT_SECONDS = 15
# Claude Code warns when a credential helper exceeds ten seconds, so
# best-effort cleanup must give up well inside that budget.
CLEANUP_TIMEOUT_SECONDS = 2
CLAUDE_MODEL_ENV = {
    "ANTHROPIC_MODEL",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
    "ANTHROPIC_DEFAULT_OPUS_MODEL",
    "ANTHROPIC_DEFAULT_SONNET_MODEL",
    "ANTHROPIC_REASONING_MODEL",
}
CLAUDE_CONNECTION_SETTINGS = {
    "ANTHROPIC_BASE_URL",
    "ANTHROPIC_AUTH_TOKEN",
    "ANTHROPIC_API_KEY",
    "CLAUDE_CODE_OAUTH_TOKEN",
    "CLAUDE_CODE_USE_BEDROCK",
    "CLAUDE_CODE_USE_FOUNDRY",
    "CLAUDE_CODE_USE_VERTEX",
}
CLAUDE_TOP_LEVEL_CONNECTION_SETTINGS = {
    "apiKeyHelper",
    "awsAuthRefresh",
    "awsCredentialExport",
    "forceLoginMethod",
    "forceLoginOrgUUID",
}


class RemoteCCError(RuntimeError):
    def __init__(self, message: str, status: int = 0, code: str = "") -> None:
        super().__init__(message)
        self.status = status
        self.code = code


def api_request(
    server: str,
    method: str,
    path: str,
    payload: Optional[Dict[str, Any]] = None,
    authorization: Optional[str] = None,
    timeout: int = CONTROL_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
    body = None
    headers = {"Accept": "application/json", "User-Agent": f"remotecc/{VERSION}"}
    if payload is not None:
        body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
        headers["Content-Type"] = "application/json"
    if authorization:
        headers["Authorization"] = authorization
    request = urllib.request.Request(
        server.rstrip("/") + path,
        data=body,
        headers=headers,
        method=method,
    )
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    try:
        with opener.open(request, timeout=timeout) as response:
            if response.status == 204:
                return {}
            return json.load(response)
    except urllib.error.HTTPError as exc:
        try:
            error = json.load(exc).get("error", "http_error")
        except Exception:
            error = "http_error"
        raise RemoteCCError(
            f"server rejected request ({exc.code}: {error})", exc.code, error
        ) from None
    except urllib.error.URLError as exc:
        raise RemoteCCError(f"cannot reach RemoteCC server: {exc.reason}") from None


def create_access(args: argparse.Namespace, nonce: str) -> Dict[str, Any]:
    model = requested_model(args.cli, args.cli_args)
    return api_request(
        args.server,
        "POST",
        "/api/v1/access-requests",
        {
            "profile": args.profile,
            "client": args.cli,
            "requested_model": model,
            "requested_ttl_seconds": args.ttl,
            "client_nonce": nonce,
            "host_label": socket.gethostname()[:128],
            "user_label": getpass.getuser()[:128],
            "cwd_label": Path.cwd().name[:128],
            "client_version": VERSION,
        },
    )


def wait_for_approval(args: argparse.Namespace, access: Dict[str, Any]) -> None:
    request_id = access["request_id"]
    authorization = "Request " + access["request_token"]
    delay = max(1, min(int(access.get("poll_after_seconds", 3)), 10))
    while True:
        time.sleep(delay)
        state = api_request(
            args.server,
            "GET",
            f"/api/v1/access-requests/{request_id}",
            authorization=authorization,
        )
        status = state["status"]
        if status == "approved":
            return
        if status in {"denied", "expired", "claimed"}:
            raise RemoteCCError(f"access request ended with status: {status}")


def claim_access(args: argparse.Namespace, access: Dict[str, Any], nonce: str) -> Dict[str, Any]:
    return api_request(
        args.server,
        "POST",
        f"/api/v1/access-requests/{access['request_id']}/claim",
        {"client_nonce": nonce},
        "Request " + access["request_token"],
    )


def requested_model(cli: str, cli_args: List[str]) -> str:
    flags = {"--model"}
    if cli == "codex":
        flags.add("-m")
    for index, value in enumerate(cli_args[:-1]):
        if value in flags:
            return cli_args[index + 1]
    for value in cli_args:
        if value.startswith("--model="):
            return value.split("=", 1)[1]
    return ""


def child_environment(cli: str, gateway_url: str, token: str) -> Dict[str, str]:
    environment = os.environ.copy()
    for name in list(environment):
        if name.startswith("ANTHROPIC_") or name in {
            "OPENAI_API_KEY",
            "OPENAI_BASE_URL",
            "REMOTECC_SESSION_TOKEN",
            "CLAUDE_CODE_OAUTH_TOKEN",
            "CLAUDE_CODE_USE_BEDROCK",
            "CLAUDE_CODE_USE_FOUNDRY",
            "CLAUDE_CODE_USE_VERTEX",
        }:
            environment.pop(name, None)
    if cli == "claude":
        environment.update(
            {
                "ANTHROPIC_BASE_URL": gateway_url,
                "ANTHROPIC_AUTH_TOKEN": token,
                "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
                "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": "1",
            }
        )
    else:
        environment["REMOTECC_SESSION_TOKEN"] = token
    return environment


def child_command(cli: str, gateway_url: str, cli_args: List[str]) -> List[str]:
    if cli == "claude":
        return ["claude", *cli_args]
    return [
        "codex",
        "-c",
        'model_provider="remotecc"',
        "-c",
        'model_providers.remotecc.name="RemoteCC"',
        "-c",
        f'model_providers.remotecc.base_url={json.dumps(gateway_url)}',
        "-c",
        'model_providers.remotecc.wire_api="responses"',
        "-c",
        'model_providers.remotecc.env_key="REMOTECC_SESSION_TOKEN"',
        *cli_args,
    ]


def load_settings_file(path: Path) -> Dict[str, Any]:
    try:
        if path.is_symlink() or not path.is_file() or path.stat().st_size > 512 << 10:
            raise ValueError
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError, json.JSONDecodeError):
        raise RemoteCCError(f"invalid Claude settings file: {path}") from None
    if not isinstance(data, dict):
        raise RemoteCCError(f"Claude settings must be a JSON object: {path}")
    return data


def sanitized_claude_settings(path: Path) -> tuple[Dict[str, Any], List[str]]:
    merged = load_settings_file(path)
    removed: List[str] = []
    for key in CLAUDE_TOP_LEVEL_CONNECTION_SETTINGS:
        if key in merged:
            removed.append(key)
            merged.pop(key, None)
    env = merged.get("env")
    if isinstance(env, dict):
        clean_env = {}
        for key, value in env.items():
            blocked = key in CLAUDE_CONNECTION_SETTINGS or (key.startswith("ANTHROPIC_") and key not in CLAUDE_MODEL_ENV)
            if blocked:
                removed.append("env." + key)
            elif isinstance(key, str) and isinstance(value, str):
                clean_env[key] = value
        merged["env"] = clean_env
    return merged, sorted(removed)


@contextlib.contextmanager
def prepared_cli_args(args: argparse.Namespace):
    if args.cli != "claude":
        yield args.cli_args
        return
    for value in args.cli_args:
        if value == "--bare" or value == "--settings" or value.startswith("--settings=") or value == "--setting-sources" or value.startswith("--setting-sources="):
            raise RemoteCCError("use RemoteCC --claude-settings instead of direct Claude settings/source overrides")
    additional_path = Path(args.claude_settings).expanduser() if args.claude_settings else None
    if additional_path is not None and not additional_path.exists():
        raise RemoteCCError(f"Claude settings file not found: {additional_path}")
    if additional_path is None:
        yield args.cli_args
        return
    settings, removed = sanitized_claude_settings(additional_path)
    if removed:
        print("RemoteCC removed connection/auth settings: " + ", ".join(removed), file=sys.stderr)
    with tempfile.TemporaryDirectory(prefix="remotecc-claude-settings-") as directory:
        settings_path = Path(directory) / "settings.json"
        settings_path.write_text(json.dumps(settings, separators=(",", ":")), encoding="utf-8")
        settings_path.chmod(0o600)
        # --settings is an additional high-precedence layer. Claude continues to
        # load managed, project-local, shared-project and user settings natively.
        yield ["--settings", str(settings_path), *args.cli_args]


def run_child(command: List[str], environment: Dict[str, str]) -> int:
    try:
        child = subprocess.Popen(command, env=environment)
    except FileNotFoundError:
        raise RemoteCCError(f"CLI executable not found: {command[0]}") from None

    previous_handlers: Dict[int, Any] = {}

    def forward(signum: int, _frame: Any) -> None:
        if child.poll() is None:
            child.send_signal(signum)

    for signum in (signal.SIGTERM, signal.SIGHUP):
        previous_handlers[signum] = signal.signal(signum, forward)
    try:
        return child.wait()
    except KeyboardInterrupt:
        return child.wait()
    finally:
        for signum, handler in previous_handlers.items():
            signal.signal(signum, handler)


def revoke(args: argparse.Namespace, session: Dict[str, Any]) -> None:
    try:
        api_request(
            args.server,
            "POST",
            f"/api/v1/sessions/{session['session_id']}/revoke",
            {},
            "Bearer " + session["gateway_token"],
        )
    except RemoteCCError as exc:
        print(f"remotecc: warning: session revoke failed: {exc}", file=sys.stderr)


def read_secret_file(path: Path) -> str:
    """Read a credential file, refusing anything group- or world-readable."""
    try:
        if path.is_symlink() or not path.is_file():
            raise ValueError
        info = path.stat()
        if info.st_size > 4096:
            raise ValueError
        if info.st_mode & 0o077:
            raise RemoteCCError(
                f"grant file must not be group- or world-accessible: {path} "
                f"(run: chmod 600 {path})"
            )
        secret = path.read_text(encoding="utf-8").strip()
    except OSError:
        raise RemoteCCError(f"cannot read grant file: {path}") from None
    except ValueError:
        raise RemoteCCError(f"invalid grant file: {path}") from None
    if not secret:
        raise RemoteCCError(f"grant file is empty: {path}")
    return secret


def process_identity(pid: int) -> Optional[str]:
    """Identify a process by pid and boot-relative start time.

    The start time is what makes this safe: a recycled pid gets a different
    value, so one session's cached token can never be served to another.
    Returns None where /proc is unavailable, and callers then fall back to a
    single shared cache entry.
    """
    try:
        stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
    except OSError:
        return None
    # The comm field may contain spaces and parentheses, so split after it.
    tail = stat.rpartition(")")[2].split()
    if len(tail) < 20:
        return None
    return f"{pid}:{tail[19]}"


def process_is_alive(identity: str) -> bool:
    pid, _, start = identity.partition(":")
    try:
        return process_identity(int(pid)) == identity
    except ValueError:
        return False


def cache_directory() -> Path:
    """Prefer a tmpfs runtime directory so cached tokens never hit disk."""
    runtime = os.environ.get("XDG_RUNTIME_DIR")
    if runtime and Path(runtime).is_dir():
        return Path(runtime)
    return Path(tempfile.gettempdir())


def project_scope() -> str:
    """The project directory owns the session.

    Claude Code runs the helper with the project as its working directory, and
    the project is the unit its own settings and permission rules are scoped
    to, so a session named after it stays put when an agent restarts — which
    Happy does whenever a conversation moves between phone and computer.
    """
    try:
        return str(Path.cwd().resolve())
    except OSError:
        return ""


def token_cache_path(server: str, grant_file: Path, scope: Optional[str]) -> Path:
    """One cache entry per project.

    Separate entries mean separate gateway sessions, so the control room can
    name and revoke one project's access instead of only the whole grant.
    """
    material = server.rstrip("/") + "\0" + str(grant_file.resolve()) + "\0" + (scope or "shared")
    key = hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
    return cache_directory() / f"remotecc-token-{key}.json"


def read_cache(path: Path) -> Optional[Dict[str, Any]]:
    try:
        if path.is_symlink() or not path.is_file() or path.stat().st_mode & 0o077:
            return None
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError, json.JSONDecodeError):
        return None
    return data if isinstance(data, dict) else None


def cached_token(path: Path, now: float) -> Optional[str]:
    data = read_cache(path)
    if data is None:
        return None
    try:
        expires = float(data.get("expires_at_epoch", 0))
    except (TypeError, ValueError):
        return None
    token = data.get("gateway_token")
    if not isinstance(token, str) or not token:
        return None
    if expires - now <= TOKEN_RENEW_MARGIN_SECONDS:
        return None
    return token


def release_finished_sessions(server: str, current: Path, limit: int = 4) -> None:
    """Revoke and drop cache entries whose agent process has exited.

    Without this a long token TTL would leave one active session per finished
    conversation until it expired on its own. Only ever called on the minting
    path and capped, because Claude Code warns when a credential helper takes
    more than ten seconds; a cache hit must stay free of network work.
    """
    for path in sorted(cache_directory().glob("remotecc-token-*.json")):
        if limit <= 0:
            return
        if path == current:
            continue
        data = read_cache(path)
        if data is None:
            with contextlib.suppress(OSError):
                path.unlink()
            continue
        owner = data.get("owner")
        if isinstance(owner, str) and owner and not process_is_alive(owner):
            limit -= 1
            session_id, token = data.get("session_id"), data.get("gateway_token")
            if isinstance(session_id, str) and isinstance(token, str) and session_id and token:
                with contextlib.suppress(RemoteCCError):
                    api_request(
                        server,
                        "POST",
                        f"/api/v1/sessions/{session_id}/revoke",
                        {},
                        "Bearer " + token,
                        timeout=CLEANUP_TIMEOUT_SECONDS,
                    )
            with contextlib.suppress(OSError):
                path.unlink()


def request_state_path(cache: Path) -> Path:
    return cache.with_name(cache.name.replace("remotecc-token-", "remotecc-request-", 1))


def approval_fallback(server: str, profile: str, cache: Path) -> Dict[str, Any]:
    """Fall back to the approval flow when the grant can no longer mint.

    The helper must never block — Claude Code warns past ten seconds and gives
    up after three failures — so this cannot wait for the owner to approve. It
    creates the request, reports the verification code, and fails. The next
    invocation finds the approval and claims it, so the recovery is: see the
    error, approve in the control room, send another message.
    """
    state_path = request_state_path(cache)
    saved = read_cache(state_path)
    if saved:
        authorization = "Request " + str(saved.get("request_token", ""))
        try:
            state = api_request(
                server, "GET", f"/api/v1/access-requests/{saved.get('request_id')}",
                authorization=authorization, timeout=CLEANUP_TIMEOUT_SECONDS,
            )
            status = state.get("status")
        except RemoteCCError:
            status = "expired"
        if status == "approved":
            session = api_request(
                server, "POST", f"/api/v1/access-requests/{saved.get('request_id')}/claim",
                {"client_nonce": saved.get("client_nonce")}, authorization,
            )
            with contextlib.suppress(OSError):
                state_path.unlink()
            return session
        if status == "pending":
            raise RemoteCCError(
                "the device grant is no longer valid; approve request "
                f"{saved.get('user_code')} in the control room, then send another message"
            )
        with contextlib.suppress(OSError):
            state_path.unlink()

    nonce = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii")
    created = api_request(server, "POST", "/api/v1/access-requests", {
        "profile": profile,
        "client": "claude",
        "requested_ttl_seconds": 0,
        "client_nonce": nonce,
        "host_label": socket.gethostname()[:128],
        "user_label": getpass.getuser()[:128],
        "cwd_label": Path.cwd().name[:128],
        "client_version": VERSION,
    })
    with contextlib.suppress(RemoteCCError):
        store_request(state_path, created, nonce)
    raise RemoteCCError(
        "the device grant is no longer valid; approve request "
        f"{created.get('user_code')} in the control room, then send another message"
    )


def store_request(path: Path, created: Dict[str, Any], nonce: str) -> None:
    payload = json.dumps({
        "request_id": created.get("request_id"),
        "request_token": created.get("request_token"),
        "user_code": created.get("user_code"),
        "client_nonce": nonce,
    }, separators=(",", ":"))
    descriptor, temporary = tempfile.mkstemp(dir=str(path.parent), prefix=".remotecc-request-")
    try:
        os.fchmod(descriptor, 0o600)
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(payload)
        os.replace(temporary, path)
    except OSError:
        with contextlib.suppress(OSError):
            os.unlink(temporary)
        raise RemoteCCError("cannot record the approval request") from None


def refresh_owner(path: Path, owner: Optional[str]) -> None:
    """Record the agent currently using a cached session.

    Without this, an agent that takes over a project would leave the entry
    naming its dead predecessor, and the cleanup pass would revoke the token
    the new agent is about to use.
    """
    if not owner:
        return
    data = read_cache(path)
    if data is None or data.get("owner") == owner:
        return
    token, expires = data.get("gateway_token"), data.get("expires_at_epoch")
    if not isinstance(token, str) or not isinstance(expires, (int, float)):
        return
    session_id = data.get("session_id")
    with contextlib.suppress(RemoteCCError):
        store_token(
            path, token, float(expires),
            session_id if isinstance(session_id, str) else "", owner,
        )


def store_token(
    path: Path,
    token: str,
    expires_at_epoch: float,
    session_id: str = "",
    owner: Optional[str] = None,
    base_url: str = "",
) -> None:
    payload = json.dumps(
        {
            "gateway_token": token,
            "expires_at_epoch": expires_at_epoch,
            "session_id": session_id,
            "owner": owner or "",
            "gateway_base_url": base_url,
        },
        separators=(",", ":"),
    )
    # Create with restrictive permissions before writing, then replace
    # atomically so a concurrent helper never reads a partial token.
    descriptor, temporary = tempfile.mkstemp(dir=str(path.parent), prefix=".remotecc-token-")
    try:
        os.fchmod(descriptor, 0o600)
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(payload)
        os.replace(temporary, path)
    except OSError:
        with contextlib.suppress(OSError):
            os.unlink(temporary)
        raise RemoteCCError("cannot cache gateway token") from None


def parse_expiry(value: Any, fallback_seconds: int) -> float:
    """RFC3339 from the server; fall back to a short local window on surprises.

    The server is Go, so timestamps carry up to nanosecond precision.
    datetime.fromisoformat only accepts three or six fractional digits before
    Python 3.11, and silently falling back would make the helper re-mint every
    few minutes instead of honouring the session lifetime.
    """
    now = time.time()
    if not isinstance(value, str) or not value:
        return now + fallback_seconds
    text = value.strip()
    if text.endswith(("Z", "z")):
        text = text[:-1] + "+00:00"
    match = re.match(r"^(.*\d\d:\d\d:\d\d)\.(\d+)(.*)$", text)
    if match:
        head, fraction, tail = match.groups()
        text = head + "." + (fraction + "000000")[:6] + tail
    try:
        parsed = datetime.datetime.fromisoformat(text)
    except ValueError:
        return now + fallback_seconds
    if parsed.tzinfo is None:
        return now + fallback_seconds
    return parsed.timestamp()


def mint_token(server: str, grant_file: Path) -> Dict[str, Any]:
    secret = read_secret_file(grant_file)
    return api_request(
        server,
        "POST",
        "/api/v1/device-grants/sessions",
        {},
        "Bearer " + secret,
    )


def launched_by_happy(pid: int) -> bool:
    """Report whether the agent that invoked this helper was spawned by Happy.

    Claude Code runs the credential helper through a shell, so the agent is
    several levels up rather than the immediate parent:

        /bin/sh          <- this wrapper
        /bin/sh          <- Claude Code's shell
        claude           <- the agent, argv[0] identifies who started it
        bash             <- whoever launched claude

    The chain is walked looking for the agent binary Happy installs under its
    own node_modules. An earlier version checked the parent's environment for
    HAPPY_* instead, which accepted everything: exporting HAPPY_SERVER_URL from
    a shell profile is the documented way to point Happy at a server, so every
    process on the host inherits it. Process ancestry cannot be inherited that
    way, and a human typing `claude` inside a Happy-created tmux window still
    hangs off the tmux server rather than off a Happy agent.

    This remains a guardrail, not a security boundary: anyone who can forge an
    ancestor can equally read the grant file and call the API directly. It
    keeps interactive use on the approval-gated launcher by default.
    """
    return happy_in_ancestry(pid, process_argv0, process_parent)


HAPPY_AGENT_MARKER = "/node_modules/happy/"
ANCESTRY_DEPTH = 8


def process_argv0(pid: int) -> Optional[str]:
    try:
        raw = Path(f"/proc/{pid}/cmdline").read_bytes()
    except OSError:
        return None
    return raw.split(b"\0")[0].decode("utf-8", "replace")


def process_parent(pid: int) -> Optional[int]:
    try:
        stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
    except OSError:
        return None
    # The comm field may contain spaces and parentheses, so split after it.
    tail = stat.rpartition(")")[2].split()
    if len(tail) < 2:
        return None
    try:
        return int(tail[1])
    except ValueError:
        return None


def happy_agent_ancestor(pid, argv0_of, parent_of, depth: int = ANCESTRY_DEPTH) -> Optional[int]:
    """Walk up from pid and return the pid of Happy's own agent binary.

    Split from launched_by_happy so the traversal is testable without /proc.
    The pid is returned rather than a bare boolean because the caller also
    needs it to name the session: the agent outlives the helper, whereas the
    shell that invoked the helper exits immediately.
    """
    seen = set()
    for _ in range(depth):
        if pid in seen or pid <= 1:
            return None
        seen.add(pid)
        argv0 = argv0_of(pid)
        if argv0 is None:
            return None
        if HAPPY_AGENT_MARKER in argv0:
            return pid
        parent = parent_of(pid)
        if parent is None:
            return None
        pid = parent
    return None


def happy_in_ancestry(pid, argv0_of, parent_of, depth: int = ANCESTRY_DEPTH) -> bool:
    return happy_agent_ancestor(pid, argv0_of, parent_of, depth) is not None


def command_token(argv: List[str]) -> int:
    """apiKeyHelper entry point: print one gateway token on stdout, nothing else.

    Claude Code aborts a helper that blocks, so this never waits for approval.
    Enrollment is the only interactive step and it happens once, up front.
    """
    parser = argparse.ArgumentParser(prog="remotecc.py token")
    parser.add_argument("--server", default=DEFAULT_SERVER)
    parser.add_argument("--grant-file", required=True)
    parser.add_argument("--refresh", action="store_true", help="ignore the cached token")
    parser.add_argument(
        "--profile",
        default="",
        help="profile to fall back to for approval when the grant stops working",
    )
    parser.add_argument(
        "--any-caller",
        action="store_true",
        help="serve callers that the Happy daemon did not start (default: refuse)",
    )
    parser.add_argument(
        "--shared-session",
        action="store_true",
        help="share one gateway session across projects instead of one per project",
    )
    args = parser.parse_args(argv)
    validate_server(parser, args.server)
    grant_file = Path(args.grant_file).expanduser()

    caller = os.getppid()
    agent = happy_agent_ancestor(caller, process_argv0, process_parent)
    if not args.any_caller and agent is None:
        raise RemoteCCError(
            "this grant serves Happy sessions only; start an interactive session with "
            "'remotecc.py claude <profile>' so it goes through approval, "
            "or pass --any-caller to override"
        )
    # The session belongs to the project; the agent only records who is using
    # it, so cleanup can tell an idle session from a live one. Naming the entry
    # after a process instead made it churn: Happy restarts its agent whenever a
    # conversation moves between devices, and Claude Code invokes the helper
    # through a shell that exits immediately.
    scope = None if args.shared_session else project_scope()
    owner = process_identity(agent) if agent is not None else None
    cache = token_cache_path(args.server, grant_file, scope)

    if not args.refresh:
        token = cached_token(cache, time.time())
        if token:
            # Fast path: no network work. Refreshing the recorded agent is a
            # local write, and it has to happen: a new agent taking over this
            # project would otherwise look abandoned to the cleanup pass, which
            # would revoke the token it is about to use.
            refresh_owner(cache, owner)
            print(token)
            return 0
    try:
        session = mint_token(args.server, grant_file)
    except RemoteCCError as exc:
        # A grant that has expired or been revoked is not a transient failure,
        # so fall back to asking the owner rather than just reporting an error.
        if args.profile and exc.status == 401:
            session = approval_fallback(args.server, args.profile, cache)
        else:
            raise
    token = session.get("gateway_token")
    if not isinstance(token, str) or not token:
        raise RemoteCCError("server returned no gateway token")
    expires = parse_expiry(session.get("expires_at"), 300)
    session_id = session.get("session_id")
    with contextlib.suppress(RemoteCCError):
        store_token(
            cache,
            token,
            expires,
            session_id if isinstance(session_id, str) else "",
            owner,
        )
    print(token)
    release_finished_sessions(args.server, cache)
    return 0


def command_enroll(argv: List[str]) -> int:
    """Device-code enrollment. The grant secret is delivered only to this
    process after the owner approves, so it never transits the web console."""
    parser = argparse.ArgumentParser(prog="remotecc.py enroll")
    parser.add_argument("--server", default=DEFAULT_SERVER)
    parser.add_argument("--profile", required=True)
    parser.add_argument("--client", default="claude", choices=("claude", "codex"))
    parser.add_argument("--label", default="")
    parser.add_argument("--grant-file", required=True)
    args = parser.parse_args(argv)
    validate_server(parser, args.server)
    grant_file = Path(args.grant_file).expanduser()
    if grant_file.exists():
        raise RemoteCCError(
            f"grant file already exists: {grant_file} (remove it to re-enroll)"
        )

    nonce = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii")
    enrollment = api_request(
        args.server,
        "POST",
        "/api/v1/grant-enrollments",
        {
            "profile": args.profile,
            "client": args.client,
            "label": args.label[:128],
            "client_nonce": nonce,
            "host_label": socket.gethostname()[:128],
            "user_label": getpass.getuser()[:128],
            "client_version": VERSION,
        },
    )
    print(
        f"RemoteCC device code: {enrollment['device_code']}\n"
        f"Approve at {args.server.rstrip('/')}/ then keep this process running.",
        file=sys.stderr,
    )
    authorization = "Enrollment " + enrollment["enrollment_token"]
    delay = max(1, min(int(enrollment.get("poll_after_seconds", 3)), 10))
    while True:
        time.sleep(delay)
        state = api_request(
            args.server,
            "GET",
            f"/api/v1/grant-enrollments/{enrollment['enrollment_id']}",
            authorization=authorization,
        )
        status = state["status"]
        if status == "approved":
            break
        if status in {"denied", "expired", "claimed"}:
            raise RemoteCCError(f"enrollment ended with status: {status}")

    grant = api_request(
        args.server,
        "POST",
        f"/api/v1/grant-enrollments/{enrollment['enrollment_id']}/claim",
        {"client_nonce": nonce},
        authorization,
    )
    secret = grant.get("grant_secret")
    if not isinstance(secret, str) or not secret:
        raise RemoteCCError("server returned no grant secret")
    descriptor = os.open(str(grant_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
        handle.write(secret + "\n")
    grant.pop("grant_secret", None)
    print(
        f"\nGrant {grant['grant_id']} stored in {grant_file} (mode 600).\n"
        f"Profile: {grant['profile']}  token TTL: {grant['token_ttl_seconds']}s  "
        f"grant expires: {grant['expires_at']}\n",
        file=sys.stderr,
    )
    return 0


def validate_server(parser: argparse.ArgumentParser, server: str) -> None:
    url = urllib.parse.urlsplit(server)
    is_loopback_http = url.scheme == "http" and url.hostname in {"127.0.0.1", "::1", "localhost"}
    if not ((url.scheme == "https" and url.hostname) or is_loopback_http) or url.username or url.password or url.query or url.fragment:
        parser.error("--server must use HTTPS (plain HTTP is allowed only for loopback)")


def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
    parser.add_argument("cli", choices=("claude", "codex"))
    parser.add_argument("profile")
    parser.add_argument("--server", default=DEFAULT_SERVER)
    parser.add_argument("--ttl", type=int, default=0, help="requested TTL in seconds (0 uses the profile default)")
    parser.add_argument("--claude-settings", help="additional Claude settings JSON; connection credentials are removed")
    parser.add_argument(
        "--no-reuse",
        action="store_true",
        help="require a fresh approval and revoke the session on exit, instead of "
             "reusing an approved session for this project until its TTL expires",
    )
    parser.add_argument("cli_args", nargs=argparse.REMAINDER)
    args = parser.parse_args(argv)
    if args.cli_args and args.cli_args[0] == "--":
        args.cli_args = args.cli_args[1:]
    server_url = urllib.parse.urlsplit(args.server)
    is_loopback_http = server_url.scheme == "http" and server_url.hostname in {
        "127.0.0.1",
        "::1",
        "localhost",
    }
    if not (
        (server_url.scheme == "https" and server_url.hostname)
        or is_loopback_http
    ) or server_url.username or server_url.password or server_url.query or server_url.fragment:
        parser.error("--server must use HTTPS (plain HTTP is allowed only for loopback)")
    if args.ttl < 0:
        parser.error("--ttl must not be negative")
    return args


def main(argv: Optional[List[str]] = None) -> int:
    arguments = list(sys.argv[1:] if argv is None else argv)
    # The grant subcommands must lead, so the legacy launcher invocation
    # (optionally prefixed with --server/--ttl) keeps parsing unchanged.
    if arguments and arguments[0] == "enroll":
        return command_enroll(arguments[1:])
    if arguments and arguments[0] == "token":
        return command_token(arguments[1:])
    args = parse_args(argv)
    with prepared_cli_args(args) as cli_args:
        args.cli_args = cli_args
        # An approval grants a session for its whole TTL, so relaunching inside
        # that window reuses it instead of asking again. The cache is scoped to
        # the project, matching what the owner actually approved. The trade is
        # explicit: the session then outlives the CLI rather than being revoked
        # on exit, so --no-reuse restores the stricter behaviour.
        cache = None
        if not args.no_reuse:
            cache = token_cache_path(args.server, Path(args.profile), project_scope())
            entry = read_cache(cache) or {}
            reused = cached_token(cache, time.time())
            base = entry.get("gateway_base_url")
            if reused and isinstance(base, str) and base:
                print(
                    f"RemoteCC reusing the approved session for {Path.cwd()}\n"
                    "Run with --no-reuse to require a fresh approval.",
                    file=sys.stderr,
                )
                command = child_command(args.cli, base, args.cli_args)
                environment = child_environment(args.cli, base, reused)
                try:
                    return run_child(command, environment)
                finally:
                    environment.pop("ANTHROPIC_AUTH_TOKEN", None)
                    environment.pop("REMOTECC_SESSION_TOKEN", None)

        nonce = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii")
        access = create_access(args, nonce)
        print(
            f"RemoteCC approval required: {access['user_code']}\n"
            f"Request: {access['request_id']}  Profile: {args.profile}",
            file=sys.stderr,
        )
        wait_for_approval(args, access)
        session = claim_access(args, access, nonce)
        access.pop("request_token", None)
        command = child_command(args.cli, session["gateway_base_url"], args.cli_args)
        environment = child_environment(args.cli, session["gateway_base_url"], session["gateway_token"])
        if cache is not None:
            with contextlib.suppress(RemoteCCError):
                store_token(
                    cache,
                    session["gateway_token"],
                    parse_expiry(session.get("expires_at"), 300),
                    str(session.get("session_id", "")),
                    None,
                    session["gateway_base_url"],
                )
        try:
            return run_child(command, environment)
        finally:
            # Revoking on exit would defeat the reuse the owner asked for, so
            # it only happens when the session is not being kept.
            if cache is None:
                revoke(args, session)
            environment.pop("ANTHROPIC_AUTH_TOKEN", None)
            environment.pop("REMOTECC_SESSION_TOKEN", None)
            session.pop("gateway_token", None)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except RemoteCCError as exc:
        print(f"remotecc: {exc}", file=sys.stderr)
        raise SystemExit(1) from None
    except KeyboardInterrupt:
        print("remotecc: cancelled", file=sys.stderr)
        raise SystemExit(130) from None
