diff --git a/CHANGELOG.md b/CHANGELOG.md index 105e626..871829a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.2.1 - 2026-08-15 + +- Move GitHub proxy-capability renewal into the long-lived host proxy daemon so + registered running boxes refresh every seven hours without an open Devbox + terminal, guest restart, or `.devbox.toml`/image resolution. Add + `devbox proxy refresh` for an immediate manifest-independent refresh. +- Route Homebrew's public `gh` path through the managed proxy wrapper under + `--proxy`, keeping the real binary as a private wrapper dependency and + restoring the normal Homebrew link under `--no-auth`. + ## v1.2.0 - 2026-08-15 - Persist resumable Claude Code, Codex, OpenCode, Pi, and Stado session state diff --git a/README.md b/README.md index ca14469..b2553f4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ brew install foobarto/tap/devbox Installs `devbox` and `devbox-ai-proxy` on your `PATH`. The current stable GitHub release is -[`v1.2.0`](https://github.com/foobarto/devbox/releases/tag/v1.2.0); source +[`v1.2.1`](https://github.com/foobarto/devbox/releases/tag/v1.2.1); source archives are available from that release. Config lives under `~/.config/devbox/` (or `$XDG_CONFIG_HOME/devbox`). @@ -243,7 +243,8 @@ enters the box. See [`proxy/README.md`](proxy/README.md) for the full explanation. `--proxy` is the recommended default for disposable boxes, and it auto-starts the host proxy (once, shared across boxes) — no separate launch step. Manage it with -`devbox proxy [start|stop|status]`. +`devbox proxy [start|stop|status|refresh]`. `refresh` updates every registered, +running box directly and never reads a project's `.devbox.toml`. Every authenticated proxy request is also written to a host-owned, owner-only audit log. It captures AI prompts/queries and GitHub API request payloads (with @@ -286,8 +287,17 @@ For `gh`, log in once on the host with `gh auth login`; `devbox --proxy` gives the guest CLI a dummy routing marker plus a short-lived Devbox proxy capability, then injects the host token only inside a GitHub-only TLS proxy. The capability is not a GitHub token, expires after eight hours, and is renewed every seven -hours while the `devbox --proxy` session is active. The bare proxy endpoint is -remembered on the host so re-entering a kept box renews its capability too. +hours by the long-lived host proxy daemon, independently of any `devbox` shell +or project manifest. It checks recorded box names once a minute, so a host +suspend or long idle is repaired promptly after resume without restarting the +guest. `devbox proxy refresh` forces the same update immediately. + +To prevent an agent from accidentally bypassing the wrapper with Homebrew's +absolute path, `--proxy` copies the real `gh` binary into the managed private +wrapper directory and replaces Homebrew's public `bin/gh` link with the wrapper; +`--no-auth` restores the normal Homebrew link. This is command-routing hygiene, +not containment against hostile same-user guest code, which can still locate +and execute files it is permitted to access. GitHub Enterprise hosts are not proxied. Git/GitHub SSH auth is separate: use **`--ssh-agent`**. It also enables automatic SSH-format Git commit signatures through the forwarded agent. Devbox copies the first public key exposed by `ssh-add -L` and the host Git name/email, then sets diff --git a/VERSION b/VERSION index 26aaba0..6085e94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/bin/devbox b/bin/devbox index cb964f8..60b6977 100755 --- a/bin/devbox +++ b/bin/devbox @@ -20,7 +20,8 @@ # [--cpus N|-j N] [--memory S|-M S] [--disk S|-D S] # devbox ls # devbox destroy NAME | --all | --goldens -# devbox proxy [start|stop|status|audit] manage the shared host-side proxy +# devbox proxy [start|stop|status|refresh|audit] +# manage the shared host-side proxy # devbox sessions [path|clear [--yes]] [DIR] # inspect or remove persistent AI session state # devbox --version|-V @@ -141,9 +142,6 @@ GLOBAL_CONFIG="$CONFIG_DIR/config.toml" DEFAULT_KEYS_FILE="$CONFIG_DIR/api-keys.env" AGENT_SESSION_BASE="${DEVBOX_SESSION_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/devbox/sessions}" PROXY_DEFAULT_URL="${DEVBOX_PROXY_URL:-http://host.lima.internal:4141}" -# A GitHub proxy capability is valid for eight hours. Refresh it well before -# expiry while this host-side `devbox` session remains attached to the guest. -GH_PROXY_CAPABILITY_RENEW_SECONDS="${DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS:-25200}" PROJECT_MANIFEST_NAME=".devbox.toml" # Host credential paths copied into the box under --with-creds (best-effort; @@ -850,30 +848,9 @@ renew_gh_proxy_capability() { # $1 Lima instance $2 bare http proxy URL } } -start_gh_proxy_capability_renewal() { # $1 Lima instance $2 bare http proxy URL - local name="$1" url="$2" - [[ "$GH_PROXY_CAPABILITY_RENEW_SECONDS" =~ ^[1-9][0-9]*$ ]] || \ - die "DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS must be a positive number of seconds" - ( - while sleep "$GH_PROXY_CAPABILITY_RENEW_SECONDS"; do - [[ "$(instance_status "$name")" == "Running" ]] || continue - proxy_ensure "$url" - renew_gh_proxy_capability "$name" "$url" || \ - warn "GitHub CLI proxy capability renewal failed; retrying in ${GH_PROXY_CAPABILITY_RENEW_SECONDS}s" - done - ) & - _DB_GH_PROXY_RENEW_PID=$! -} - -stop_gh_proxy_capability_renewal() { - local pid="${_DB_GH_PROXY_RENEW_PID:-}" - [[ -n "$pid" ]] || return 0 - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - unset _DB_GH_PROXY_RENEW_PID -} # is *our* proxy answering on this port? (health endpoint, not just any listener) proxy_health() { curl -fsS --max-time 1 "http://127.0.0.1:$1/_devbox" 2>/dev/null | grep -q 'devbox-ai-proxy'; } +proxy_self_renewal_health() { curl -fsS --max-time 1 "http://127.0.0.1:$1/_devbox" 2>/dev/null | grep -q 'gh-self-renewal'; } # is anything at all listening on the port? proxy_port_open() { timeout 1 bash -c ">/dev/tcp/127.0.0.1/$1" 2>/dev/null; } # locate a proxy asset across source (../proxy) and brew (../libexec/proxy) @@ -899,11 +876,34 @@ proxy_launcher() { command -v devbox-ai-proxy 2>/dev/null && return 0 return 1 } + +proxy_stop_process() { + local pid + pid="$(cat "$CONFIG_DIR/proxy.pid" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill "$pid" 2>/dev/null; then + rm -f "$CONFIG_DIR/proxy.pid" + return 0 + fi + if pkill -f 'devbox-ai-proxy\.py' 2>/dev/null; then + rm -f "$CONFIG_DIR/proxy.pid" + return 0 + fi + return 1 +} + # start the host proxy if it isn't already up (idempotent). Shared across boxes. proxy_ensure() { local url="$1" port launcher logf i port="$(proxy_port "$url")" - if proxy_health "$port"; then log "credential proxy already running on 127.0.0.1:$port"; return 0; fi + if proxy_health "$port"; then + if proxy_self_renewal_health "$port"; then + log "credential proxy already running on 127.0.0.1:$port" + return 0 + fi + log "Restarting host credential proxy once to enable persistent GitHub capability renewal" + proxy_stop_process || { warn "could not stop the older credential proxy"; return 1; } + for i in $(seq 1 20); do proxy_port_open "$port" || break; sleep 0.1; done + fi if proxy_port_open "$port"; then warn "port $port is held by another service (not devbox's proxy)." warn " pick a free port: export DEVBOX_PROXY_URL=http://host.lima.internal:" @@ -970,6 +970,38 @@ PY limactl copy "$ca_path" "$name:.devbox/gh-proxy/certs/devbox-gh-proxy-ca.pem" # shellcheck disable=SC2016 # $HOME expands inside the guest shell. limactl shell "$name" -- bash -c 'chmod 755 "$HOME/.devbox/gh-proxy/bin/gh"; chmod 644 "$HOME/.devbox/gh-proxy/certs/devbox-gh-proxy-ca.pem"' + # Keep the real Homebrew binary as a private wrapper dependency, then replace + # Homebrew's public `gh` link with the proxy wrapper. This covers agents that + # exec the usual absolute Homebrew path instead of asking a shell to resolve + # the managed gh() function. A same-user process can still deliberately find + # the private binary; this prevents accidental bypass, not hostile guest code. + # shellcheck disable=SC2016 # All variables expand in the guest. + limactl shell "$name" -- bash -s <<'GUEST' +set -euo pipefail +wrapper="$HOME/.devbox/gh-proxy/bin/gh" +real_dir="$HOME/.devbox/gh-proxy/libexec" +real_gh="$real_dir/gh-real" +local_shim="$HOME/.local/bin/gh" +brew_bin="$(command -v brew 2>/dev/null || true)" +[ -n "$brew_bin" ] || brew_bin=/home/linuxbrew/.linuxbrew/bin/brew +[ -x "$brew_bin" ] || { printf '%s\n' '[devbox] Homebrew is required for managed gh proxying' >&2; exit 1; } +formula_prefix="$("$brew_bin" --prefix gh 2>/dev/null || true)" +formula_gh="$formula_prefix/bin/gh" +[ -n "$formula_prefix" ] && [ -x "$formula_gh" ] \ + || { printf '%s\n' '[devbox] Homebrew gh is missing; rebuild the golden and recreate this box' >&2; exit 1; } +install -d -m 700 "$real_dir" "$HOME/.local/bin" +install -m 755 "$formula_gh" "$real_gh" + +brew_prefix="$("$brew_bin" --prefix)" +brew_gh="$brew_prefix/bin/gh" +rm -f -- "$brew_gh" +ln -s "$wrapper" "$brew_gh" +if [ ! -e "$local_shim" ] && [ ! -L "$local_shim" ]; then + ln -s "$wrapper" "$local_shim" +elif [ "$(readlink -f "$local_shim" 2>/dev/null || true)" != "$wrapper" ]; then + printf '[devbox] WARN: preserving existing gh command at %s\n' "$local_shim" >&2 +fi +GUEST renew_gh_proxy_capability "$name" "$url" || die "could not issue GitHub CLI proxy capability" limactl shell "$name" -- bash -c 'sudo tee /etc/profile.d/zz-devbox-12-gh-proxy.sh >/dev/null' </dev/null || true)" = "$wrapper" ]; then + rm -f -- "$brew_gh" + restore_brew=1 + fi +fi +if [ -L "$local_shim" ] && [ "$(readlink -f "$local_shim" 2>/dev/null || true)" = "$wrapper" ]; then + rm -f -- "$local_shim" +fi +sudo rm -f /etc/profile.d/zz-devbox-10-proxy.sh \ + /etc/profile.d/zz-devbox-11-codex-proxy.sh \ + /etc/profile.d/zz-devbox-12-gh-proxy.sh \ + /etc/profile.d/zz-devbox-20-keys.sh +rm -rf -- "$HOME/.devbox/codex-proxy" "$HOME/.devbox/gh-proxy" +if [ "$restore_brew" -eq 1 ]; then + "$brew_bin" link --overwrite gh >/dev/null +fi +GUEST } # ------------------------------------------------------- traffic audit egress --- @@ -1723,7 +1780,6 @@ cmd_build() { run_cleanup() { local rc=$? trap - EXIT INT TERM - stop_gh_proxy_capability_renewal if [[ "${_DB_KEEP:-0}" -eq 1 ]]; then log "--keep: retained ${_DB_NAME:-?}" log " re-enter: devbox $(printf '%q' "${_DB_DIR:-.}")$([[ "${_DB_IMAGE:-}" != "$DEFAULT_IMAGE" ]] && printf ' --image %q' "${_DB_IMAGE:-}")" @@ -1954,7 +2010,6 @@ cmd_run() { [[ -z "$managed_proxy" ]] || { proxy_ensure "$managed_proxy" apply_proxy "$name" "$managed_proxy" - start_gh_proxy_capability_renewal "$name" "$managed_proxy" } [[ -n "$api_keys" ]] && apply_api_keys "$name" "$api_keys" [[ $with_creds -eq 1 ]] && apply_creds "$name" @@ -2023,11 +2078,17 @@ cmd_proxy() { case "$sub" in start) proxy_ensure "$url";; stop) pid="$(cat "$CONFIG_DIR/proxy.pid" 2>/dev/null || true)" - if [[ -n "$pid" ]] && kill "$pid" 2>/dev/null; then - log "stopped AI proxy (pid $pid)"; rm -f "$CONFIG_DIR/proxy.pid" - elif pkill -f 'devbox-ai-proxy\.py' 2>/dev/null; then log "stopped AI proxy (matched by name)" + if proxy_stop_process; then log "stopped AI proxy${pid:+ (pid $pid)}" else warn "no running AI proxy found"; fi;; - status) if proxy_health "$port"; then log "AI proxy: RUNNING on 127.0.0.1:$port" + refresh) proxy_ensure "$url" + launcher="$(proxy_launcher)" || die "proxy launcher not found" + "$launcher" --refresh-gh-proxy-boxes;; + status) if proxy_health "$port"; then + if proxy_self_renewal_health "$port"; then + log "AI proxy: RUNNING on 127.0.0.1:$port (GitHub capability self-renewal enabled)" + else + warn "AI proxy: RUNNING on 127.0.0.1:$port (restart required for GitHub capability self-renewal)" + fi elif proxy_port_open "$port"; then warn "port $port held by another (non-devbox) service" else log "AI proxy: not running (port $port)"; fi;; audit) shift @@ -2039,7 +2100,7 @@ cmd_proxy() { export) audit_arg="${2:-}"; "$launcher" --audit-export "$audit_arg";; *) die "usage: devbox proxy audit [status|show [LIMIT]|export [FILE]]";; esac;; - *) die "usage: devbox proxy [start|stop|status|audit]";; + *) die "usage: devbox proxy [start|stop|status|refresh|audit]";; esac } diff --git a/docs/agent-capabilities-security.md b/docs/agent-capabilities-security.md index cafe714..c42cb50 100644 --- a/docs/agent-capabilities-security.md +++ b/docs/agent-capabilities-security.md @@ -98,6 +98,14 @@ guest does not receive an access or refresh token. The GitHub wrapper also rejects guest-side token-changing `gh auth` commands. See the [proxy design](../proxy/README.md). +For `gh`, the host proxy daemon renews short-lived capabilities directly for +host-registered running Lima boxes; it does not re-evaluate project manifests or +restart guests. `--proxy` replaces Homebrew's public `gh` link with the wrapper +to prevent accidental direct execution, while retaining a private executable +copy for the wrapper itself. Because both remain executable by the guest user, +this routing measure does not stop deliberately hostile same-user code from +finding and invoking the private binary. + The host records detailed authenticated-proxy request audits by default, including prompts and GitHub mutation payloads. This helps attribute actions, but creates a second sensitive host-local data store. Read [proxy audit diff --git a/proxy/README.md b/proxy/README.md index 9afd5aa..0c67e6e 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -129,12 +129,21 @@ into the guest, and injects the host token after TLS termination for `api.github.com` and `uploads.github.com`. The guest holds only the literal `devbox-proxy` routing marker plus a short-lived Devbox proxy capability, never the real GitHub token. The capability authenticates only the local proxy and -expires after eight hours. While the host-side `devbox --proxy` session remains -open, Devbox renews that capability every seven hours and atomically updates the -guest wrapper state. Devbox remembers only the bare proxy endpoint on the host, -so re-entering a kept box renews its capability even when `--proxy` is omitted. +expires after eight hours. The long-lived proxy daemon scans host-owned box +registrations once a minute and renews due capabilities every seven hours. +Renewal uses the recorded Lima box name directly: it does not depend on a +`devbox` terminal remaining open, re-read `.devbox.toml`, start a stopped box, +or restart a running guest. The wrapper reads the atomically replaced +capability file for every invocation. GitHub-owned download hosts are tunnelled without TLS interception. +`--proxy` also moves the usable Homebrew `gh` binary into the wrapper's managed +private directory and replaces Homebrew's public `bin/gh` link with the wrapper. +That prevents ordinary child-process and absolute-Homebrew-path mistakes. The +real binary must remain executable by the same guest user for the wrapper to run +it, so this is not a security boundary against deliberately hostile guest code. +`--no-auth` restores Homebrew's normal link. + Log in on the host first: ```sh @@ -149,22 +158,20 @@ for GitHub.com; GitHub Enterprise hosts remain direct guest configuration. ### Repairing an existing box -For a kept box where `gh` is installed but reports that it needs `gh auth -login`, refresh the Devbox-managed proxy setup without deleting the box: +For an immediate refresh of every registered running box, without resolving a +project manifest or opening/restarting a guest, run: ```sh -cd /path/to/project -devbox --keep --proxy +devbox proxy refresh ``` -The command refreshes the guest wrapper and proxy profile, then opens the box. -Use `gh api /rate_limit --jq .rate.remaining` there as a credential-safe smoke -check. Do not run `gh auth login` in the guest; log in on the host instead. -For a session that was closed or suspended past the capability lifetime, the -same command issues a fresh capability before opening the guest. Subsequent -bare re-entry to that kept box does the same, using the host-owned remembered -endpoint; use `--no-auth` to remove the proxy configuration and remembered -endpoint. +Use `gh api /rate_limit --jq .rate.remaining` in the existing guest as a +credential-safe smoke check. Do not run `gh auth login` in the guest; log in on +the host instead. Re-entering a kept box still repairs the wrapper/profile and +records it for daemon renewal, but is no longer needed for routine refreshes. +After upgrading from a proxy version without daemon renewal, the first new +`devbox --proxy`, `devbox proxy start`, or `devbox proxy refresh` restarts only +the host proxy process once; it does not restart any guest. If the box says `gh` is missing, it predates the golden-image installation. First check that its project work is committed or otherwise safe, then rebuild @@ -199,6 +206,7 @@ Manage the shared proxy directly if you want: ```sh devbox proxy status # RUNNING / not running / port held by another service devbox proxy start # start it without a box +devbox proxy refresh # renew every registered running box; no guest restart devbox proxy stop # stop it ``` diff --git a/proxy/devbox-ai-proxy.py b/proxy/devbox-ai-proxy.py index 87bbb5b..372d402 100755 --- a/proxy/devbox-ai-proxy.py +++ b/proxy/devbox-ai-proxy.py @@ -27,6 +27,7 @@ import ipaddress import json import os +import re import select import secrets import socket @@ -38,7 +39,7 @@ import time from datetime import UTC, datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlencode, urlsplit +from urllib.parse import quote, urlencode, urlsplit, urlunsplit CONFIG_PATH = os.environ.get( "DEVBOX_PROXY_CONFIG", @@ -99,6 +100,26 @@ GITHUB_MITM_HOSTS = {"api.github.com", "uploads.github.com"} GITHUB_PROXY_TOKEN_TTL_SECONDS = 8 * 60 * 60 TRAFFIC_PROXY_TOKEN_TTL_SECONDS = 8 * 60 * 60 +try: + GITHUB_PROXY_RENEW_SECONDS = int( + os.environ.get("DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS", 7 * 60 * 60) + ) + GITHUB_PROXY_RENEW_POLL_SECONDS = int( + os.environ.get("DEVBOX_GH_PROXY_CAPABILITY_POLL_SECONDS", 60) + ) +except ValueError as exc: + raise SystemExit("GitHub proxy capability renewal intervals must be integers") from exc +_LIMA_INSTANCE_NAME = re.compile(r"^[A-Za-z0-9_.-]+$") +_GITHUB_PROXY_URL_UPDATE_SCRIPT = r''' +set -e +state_dir="$HOME/.devbox/gh-proxy" +umask 077 +install -d -m 700 "$state_dir" +tmp="$(mktemp "$state_dir/.proxy-url.XXXXXX")" +cat > "$tmp" +chmod 600 "$tmp" +mv -f "$tmp" "$state_dir/proxy-url" +''' # hop-by-hop + length/host headers we never forward verbatim DROP = { @@ -716,6 +737,183 @@ def valid_github_proxy_token(token: str) -> bool: return False +def github_proxy_registration_dir() -> str: + return os.path.join(STATE_DIR, "gh-proxy-boxes") + + +def registered_github_proxy_boxes() -> dict[str, str]: + """Return host-approved Lima box names and their bare proxy endpoints.""" + directory = github_proxy_registration_dir() + registrations = {} + try: + entries = list(os.scandir(directory)) + except FileNotFoundError: + return registrations + except OSError as exc: + raise RuntimeError(f"could not read GitHub proxy registrations: {exc}") from exc + + for entry in entries: + if not entry.name.endswith(".url") or not entry.is_file(follow_symlinks=False): + continue + name = entry.name[:-4] + if not _LIMA_INSTANCE_NAME.fullmatch(name): + continue + try: + with open(entry.path, encoding="utf-8") as endpoint_file: + endpoint = endpoint_file.read(4096).strip() + parsed = urlsplit(endpoint) + endpoint_port = parsed.port or 4141 + except (OSError, ValueError): + continue + if ( + parsed.scheme != "http" + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + or endpoint_port != BIND_PORT + ): + continue + registrations[name] = endpoint + return registrations + + +def running_lima_instances() -> set[str]: + """List running Lima instances without starting or changing any guest.""" + try: + result = subprocess.run( + ["limactl", "list", "--json"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"could not list Lima instances: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or f"exit {result.returncode}" + raise RuntimeError(f"could not list Lima instances: {detail}") + + items = [] + output = result.stdout.strip() + if not output: + return set() + try: + decoded = json.loads(output) + items.extend(decoded if isinstance(decoded, list) else [decoded]) + except json.JSONDecodeError: + try: + items.extend(json.loads(line) for line in output.splitlines() if line.strip()) + except json.JSONDecodeError as exc: + raise RuntimeError("could not parse Lima instance list") from exc + return { + item.get("name") + for item in items + if isinstance(item, dict) + and item.get("status") == "Running" + and isinstance(item.get("name"), str) + } + + +def github_proxy_url(endpoint: str, capability: str) -> str: + parsed = urlsplit(endpoint) + return urlunsplit(( + parsed.scheme, + f"{quote(capability, safe='')}@{parsed.netloc}", + "", + "", + "", + )) + + +def deliver_github_proxy_capability(name: str, endpoint: str) -> None: + """Atomically update a running guest; capability bytes travel over stdin.""" + capability_url = github_proxy_url(endpoint, issue_github_proxy_token()) + try: + result = subprocess.run( + ["limactl", "shell", name, "--", "bash", "-c", _GITHUB_PROXY_URL_UPDATE_SCRIPT], + input=capability_url + "\n", + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"could not update {name}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or f"exit {result.returncode}" + raise RuntimeError(f"could not update {name}: {detail}") + + +def refresh_registered_github_proxy_boxes( + renewed_at: dict[str, tuple[str, float]] | None = None, + *, + force: bool = False, + now: float | None = None, +) -> dict[str, int]: + """Renew due capabilities by recorded box name, independent of manifests.""" + if GITHUB_PROXY_RENEW_SECONDS <= 0: + raise RuntimeError("DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS must be positive") + registrations = registered_github_proxy_boxes() + running = running_lima_instances() if registrations else set() + timestamp = time.time() if now is None else now + history = renewed_at if renewed_at is not None else {} + for stale_name in set(history) - set(registrations): + history.pop(stale_name, None) + + summary = { + "registered": len(registrations), + "running": 0, + "renewed": 0, + "failed": 0, + } + for name, endpoint in sorted(registrations.items()): + if name not in running: + continue + summary["running"] += 1 + previous = history.get(name) + if ( + not force + and previous is not None + and previous[0] == endpoint + and timestamp - previous[1] < GITHUB_PROXY_RENEW_SECONDS + ): + continue + try: + deliver_github_proxy_capability(name, endpoint) + except RuntimeError as exc: + summary["failed"] += 1 + sys.stderr.write(f"[devbox-ai-proxy] GitHub capability renewal failed: {exc}\n") + continue + history[name] = (endpoint, timestamp) + summary["renewed"] += 1 + return summary + + +def maintain_github_proxy_capabilities() -> None: + """Keep registered running guests current for the proxy daemon's lifetime.""" + if GITHUB_PROXY_RENEW_POLL_SECONDS <= 0: + sys.stderr.write( + "[devbox-ai-proxy] GitHub capability renewal disabled: " + "DEVBOX_GH_PROXY_CAPABILITY_POLL_SECONDS must be positive\n" + ) + return + renewed_at: dict[str, tuple[str, float]] = {} + while True: + try: + summary = refresh_registered_github_proxy_boxes(renewed_at) + if summary["renewed"]: + sys.stderr.write( + "[devbox-ai-proxy] renewed GitHub capability for %d running box(es)\n" + % summary["renewed"] + ) + except RuntimeError as exc: + sys.stderr.write(f"[devbox-ai-proxy] GitHub capability renewal check failed: {exc}\n") + time.sleep(GITHUB_PROXY_RENEW_POLL_SECONDS) + + def github_proxy_authorized(headers) -> bool: """Validate the short-lived Basic-proxy credential supplied by the wrapper.""" authorization = headers.get("Proxy-Authorization", "") @@ -1074,7 +1272,7 @@ def _proxy(self): # Health/identity endpoint so callers can distinguish this proxy from # any other service that happens to hold the port. if self.path.startswith("/_devbox"): - body = b"devbox-ai-proxy ok\n" + body = b"devbox-ai-proxy ok gh-self-renewal\n" self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) @@ -1350,6 +1548,19 @@ def main(): if args == ["--new-traffic-proxy-token"]: print(issue_traffic_proxy_token()) return + if args == ["--refresh-gh-proxy-boxes"]: + try: + summary = refresh_registered_github_proxy_boxes(force=True) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + print( + "GitHub proxy capabilities: " + f"{summary['renewed']} renewed, {summary['running']} running, " + f"{summary['registered']} registered, {summary['failed']} failed" + ) + if summary["failed"]: + raise SystemExit(1) + return if args == ["--audit-status"]: print(json.dumps(audit_status(), sort_keys=True)) return @@ -1384,6 +1595,16 @@ def main(): "[devbox-ai-proxy] host OAuth refresh enabled (checks every %ss)\n" % REFRESH_POLL_SECONDS ) + threading.Thread( + target=maintain_github_proxy_capabilities, + name="devbox-gh-capability-refresh", + daemon=True, + ).start() + sys.stderr.write( + "[devbox-ai-proxy] GitHub capability self-renewal enabled " + "(checks every %ss, renews every %ss)\n" + % (GITHUB_PROXY_RENEW_POLL_SECONDS, GITHUB_PROXY_RENEW_SECONDS) + ) try: srv.serve_forever() except KeyboardInterrupt: diff --git a/proxy/gh-wrapper.py b/proxy/gh-wrapper.py index dc1ba3f..d3a623f 100644 --- a/proxy/gh-wrapper.py +++ b/proxy/gh-wrapper.py @@ -14,6 +14,9 @@ def real_gh() -> str | None: """Find the golden's gh binary without recursing back into this wrapper.""" wrapper = os.path.realpath(__file__) + managed_real = os.path.join(os.path.dirname(os.path.dirname(wrapper)), "libexec", "gh-real") + if os.path.isfile(managed_real) and os.access(managed_real, os.X_OK): + return managed_real for directory in os.environ.get("PATH", "").split(os.pathsep): candidate = os.path.join(directory or ".", "gh") if os.path.isfile(candidate) and os.access(candidate, os.X_OK): diff --git a/test/devbox.bats b/test/devbox.bats index 5c06965..903bb5b 100644 --- a/test/devbox.bats +++ b/test/devbox.bats @@ -379,9 +379,30 @@ setup() { [[ "$source_text" == *'gh() {'* ]] [[ "$source_text" == *'DEVBOX_GH_PROXY_URL_FILE'* ]] [[ "$source_text" == *'renew_gh_proxy_capability'* ]] - [[ "$source_text" == *'start_gh_proxy_capability_renewal'* ]] + [[ "$source_text" != *'start_gh_proxy_capability_renewal'* ]] [[ "$source_text" == *'stored_gh_proxy_endpoint'* ]] - [[ "$source_text" == *'rm -rf "$HOME/.devbox/codex-proxy" "$HOME/.devbox/gh-proxy"'* ]] + [[ "$source_text" == *'formula_prefix="$("$brew_bin" --prefix gh'* ]] + [[ "$source_text" == *'real_gh="$real_dir/gh-real"'* ]] + [[ "$source_text" == *'ln -s "$wrapper" "$brew_gh"'* ]] + [[ "$source_text" == *'"$brew_bin" link --overwrite gh'* ]] + [[ "$source_text" == *'rm -rf -- "$HOME/.devbox/codex-proxy" "$HOME/.devbox/gh-proxy"'* ]] +} + +@test "proxy refresh bypasses project manifest and image resolution" { + project="$BATS_TEST_TMPDIR/project" + fake_launcher="$BATS_TEST_TMPDIR/proxy-launcher" + mkdir -p "$project" + printf '%s\n' 'this is deliberately not valid TOML' > "$project/.devbox.toml" + printf '%s\n' '#!/usr/bin/env bash' 'printf "launcher:%s\n" "$1"' > "$fake_launcher" + chmod +x "$fake_launcher" + proxy_ensure() { printf 'ensure:%s\n' "$1"; } + proxy_launcher() { printf '%s' "$fake_launcher"; } + + cd "$project" + output="$(cmd_proxy refresh)" + + [[ "$output" == *'ensure:http://host.lima.internal:4141'* ]] + [[ "$output" == *'launcher:--refresh-gh-proxy-boxes'* ]] } @test "proxy command exposes host-owned audit viewing and HTML export" { diff --git a/test/proxy_test.py b/test/proxy_test.py index bd0a753..cd7123c 100644 --- a/test/proxy_test.py +++ b/test/proxy_test.py @@ -234,8 +234,110 @@ def test_connect_rejects_an_expired_capability(self): with patch.object(proxy.time, "time", return_value=proxy.time.time() + 61): self.assertFalse(proxy.github_proxy_authorized(headers)) + def test_daemon_renews_registered_running_box_without_project_resolution(self): + with tempfile.TemporaryDirectory() as directory: + registrations = Path(directory) / "gh-proxy-boxes" + registrations.mkdir() + (registrations / "devbox-existing-1234.url").write_text( + "http://host.lima.internal:4141\n" + ) + history = {} + with patch.object(proxy, "STATE_DIR", directory), \ + patch.object(proxy, "BIND_PORT", 4141), \ + patch.object(proxy, "GITHUB_PROXY_RENEW_SECONDS", 100), \ + patch.object(proxy, "running_lima_instances", return_value={"devbox-existing-1234"}), \ + patch.object(proxy, "deliver_github_proxy_capability") as deliver: + first = proxy.refresh_registered_github_proxy_boxes(history, now=1000) + second = proxy.refresh_registered_github_proxy_boxes(history, now=1050) + third = proxy.refresh_registered_github_proxy_boxes(history, now=1101) + + self.assertEqual(first, {"registered": 1, "running": 1, "renewed": 1, "failed": 0}) + self.assertEqual(second["renewed"], 0) + self.assertEqual(third["renewed"], 1) + self.assertEqual(deliver.call_count, 2) + deliver.assert_called_with("devbox-existing-1234", "http://host.lima.internal:4141") + + def test_daemon_does_not_start_a_stopped_registered_box(self): + with tempfile.TemporaryDirectory() as directory: + registrations = Path(directory) / "gh-proxy-boxes" + registrations.mkdir() + (registrations / "devbox-stopped.url").write_text( + "http://host.lima.internal:4141\n" + ) + with patch.object(proxy, "STATE_DIR", directory), \ + patch.object(proxy, "BIND_PORT", 4141), \ + patch.object(proxy, "running_lima_instances", return_value=set()), \ + patch.object(proxy, "deliver_github_proxy_capability") as deliver: + summary = proxy.refresh_registered_github_proxy_boxes(force=True) + + self.assertEqual(summary, {"registered": 1, "running": 0, "renewed": 0, "failed": 0}) + deliver.assert_not_called() + + def test_capability_delivery_keeps_token_out_of_process_arguments(self): + completed = Mock(returncode=0, stdout="", stderr="") + with patch.object(proxy, "issue_github_proxy_token", return_value="part.one"), \ + patch.object(proxy.subprocess, "run", return_value=completed) as run: + proxy.deliver_github_proxy_capability( + "devbox-existing-1234", + "http://host.lima.internal:4141", + ) + + arguments = run.call_args.args[0] + self.assertNotIn("part.one", " ".join(arguments)) + self.assertEqual( + run.call_args.kwargs["input"], + "http://part.one@host.lima.internal:4141\n", + ) + + def test_running_lima_instances_accepts_ndjson(self): + completed = Mock( + returncode=0, + stdout=( + '{"name":"devbox-running","status":"Running"}\n' + '{"name":"devbox-stopped","status":"Stopped"}\n' + ), + stderr="", + ) + with patch.object(proxy.subprocess, "run", return_value=completed): + self.assertEqual(proxy.running_lima_instances(), {"devbox-running"}) + class GitHubCliWrapperTests(TestCase): + def test_wrapper_prefers_its_private_real_binary(self): + with tempfile.TemporaryDirectory() as directory: + managed = Path(directory) / "gh-proxy" + wrapper = managed / "bin" / "gh" + real_gh = managed / "libexec" / "gh-real" + wrapper.parent.mkdir(parents=True) + real_gh.parent.mkdir(parents=True) + wrapper.write_text(GH_WRAPPER.read_text()) + wrapper.chmod(0o755) + real_gh.write_text( + f"#!{sys.executable}\n" + "import json, os\n" + "print(json.dumps({'argv': __import__('sys').argv[1:], " + "'token': os.environ.get('GH_TOKEN'), " + "'proxy': os.environ.get('HTTPS_PROXY')}))\n" + ) + real_gh.chmod(0o755) + result = subprocess.run( + [sys.executable, str(wrapper), "api", "/rate_limit"], + check=False, + capture_output=True, + text=True, + env={ + "PATH": "", + "DEVBOX_GH_PROXY_URL": "http://capability@host.lima.internal:4141", + "DEVBOX_GH_PROXY_CERT_DIR": "/guest/certs", + }, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + routed = json.loads(result.stdout) + self.assertEqual(routed["argv"], ["api", "/rate_limit"]) + self.assertEqual(routed["token"], "devbox-proxy") + self.assertEqual(routed["proxy"], "http://capability@host.lima.internal:4141") + def test_wrapper_reads_a_renewed_proxy_url_from_its_state_file(self): with tempfile.TemporaryDirectory() as directory: fake_gh = Path(directory) / "gh"