#!/usr/bin/env bash
# construct-host-exec — generic shim that proxies a host-binary invocation to
# the Construct host exec bridge. Installed once at /usr/local/bin/construct-host-exec
# and symlinked once per allowlisted binary (e.g. ~/.local/bin/wicket → this
# file). At runtime, basename(os.Args[0]) tells the shim which host binary to
# request; the bridge resolves and runs it on the host and streams output back
# as JSONL frames. See docs/HOST-EXEC.md.
#
# Lifecycle:
#   - reads stdin fully (the bridge is request/response, not duplex: the whole
#     stdin blob ships up front; live interactive input is unsupported by design)
#   - POSTs {argv, stdin} to $CONSTRUCT_HOST_EXEC_URL/exec with the bearer token
#   - streams JSONL frames back through curl (-N disables curl buffering so
#     frames flush as they arrive), decoding stdout/stderr/exit inline
#   - exits with the bridge-reported code; 126 if the bridge is unreachable
#
# IMPORTANT: the JSONL parse loop uses process substitution (< <(...)) rather
# than a pipe so it runs in the CURRENT shell and the exit code parsed from the
# {"type":"exit"} frame survives to the final `exit`. A pipe variant would run
# the loop in a subshell and silently lose the exit code.

set -u

URL="${CONSTRUCT_HOST_EXEC_URL:-}"
TOKEN="${CONSTRUCT_HOST_EXEC_TOKEN:-}"
NAME="$(basename "$0")"

if [[ -z "$URL" || -z "$TOKEN" ]]; then
    echo "${NAME}: host exec bridge not configured (CONSTRUCT_HOST_EXEC_URL/TOKEN unset)." >&2
    echo "${NAME}: this binary is proxied to the host; start it via 'construct <agent>'." >&2
    exit 126
fi

if ! command -v jq >/dev/null 2>&1; then
    echo "${NAME}: jq is required to talk to the host exec bridge but was not found." >&2
    exit 126
fi

# Read stdin once (the bridge expects it up front, base64-encoded).
#
# Four failure modes to avoid:
#   1. Deadlock: a bare `base64` read blocks forever when stdin is an open
#      pipe that never sends data and never closes (no EOF). This is the
#      condition created by non-interactive launchers like pi-unified-exec,
#      which hold stdin open for the session lifetime.
#   2. Latency tax: `timeout N` always runs to its full duration when no data
#      ever arrives, so strapping it on unconditionally would add a permanent
#      N-second wait to every invocation from such launchers.
#   3. Silent truncation: a byte cap or timeout can ship a partial payload,
#      surfacing as a confusing bridge-side parse error instead of a clear
#      shim-side message.
#   4. Race at startup: a parent's pipe write can land AFTER our first
#      non-blocking peek (a loaded CI runner hit exactly this), and a single
#      peek-then-give-up would ship stdin silently empty.
#
# Strategy: peek first with non-blocking `read -t 0` (returns success only
# when data or EOF is immediately available), and poll that peek across a
# short grace window so a late pipe write is still seen before we conclude
# the launcher holds stdin open-empty. Once data/EOF is pending, consume it,
# bounded by `head -c` (forces EOF after N bytes or on real EOF, driving
# base64 to flush+close) and `timeout` (defense against a slow trickle). If
# either bound fires, warn on stderr so the truncation is attributable to
# the shim, not the bridge.
STDIN_B64=""
STDIN_CAP=1048576        # 1 MiB; generous for a CLI that takes config flags
STDIN_TIMEOUT=5          # seconds; bound for a slow trickle
STDIN_GRACE_LOOPS=15     # 15 x 20ms sleeps = 300ms grace for a late writer
STDIN_TRUNCATED=0
if [[ ! -t 0 ]]; then
    _stdin_waits=0
    until read -r -N 0 -t 0 _ 2>/dev/null; do
        if (( _stdin_waits >= STDIN_GRACE_LOOPS )); then
            break
        fi
        sleep 0.02
        _stdin_waits=$((_stdin_waits + 1))
    done
    if read -r -N 0 -t 0 _ 2>/dev/null; then
        STDIN_B64="$(timeout "${STDIN_TIMEOUT}" head -c "${STDIN_CAP}" | base64 | tr -d '\n')"
        # Exit codes across `$(...)` are unreliable (we see tr's status, not
        # head's or timeout's; pipefail isn't set). Detect truncation by content
        # length: base64 of a clean EOF usually lands short of the cap; hitting
        # it precisely is the signature of `head -c` stopping at the limit.
        if [[ ${#STDIN_B64} -ge $((STDIN_CAP * 4 / 3)) ]]; then
            STDIN_TRUNCATED=1
        fi
    fi
fi
if [[ "${STDIN_TRUNCATED}" == "1" ]]; then
    echo "${NAME}: warning: stdin exceeded ${STDIN_CAP} bytes or ${STDIN_TIMEOUT}s read budget; truncated." >&2
    echo "${NAME}: the host backend received a partial stdin payload." >&2
fi

# Build the request payload safely with jq. --args consumes the remaining CLI
# args (argv[1..]) as a JSON array; we prepend $NAME (argv[0]) via a leading
# '--" so the whole argv is byte-safe (quotes/spaces/newlines/unicode cannot
# break the payload — no manual NUL-splitting, which loses embedded newlines).
# $PWD is the agent's container cwd; the bridge translates it to the matching
# host path so cwd-aware host CLIs run in the project the agent is in.
PAYLOAD="$(jq -nc --arg stdin "$STDIN_B64" --arg cwd "$PWD" --args '{argv: $ARGS.positional, stdin: $stdin, cwd: $cwd}' -- "$NAME" "$@")"

# Stream JSONL frames as they arrive. We bind curl's stdout to fd 3 via
# process substitution and capture its PID so we can read its real exit code
# afterwards: `$?` after a `while ... done < <(cmd)` loop reflects the loop's
# exit, not curl's, so the unreachable branch would never fire otherwise.
exit_code=""
exec 3< <(curl -sS -N \
            -H "X-Construct-Exec-Token: ${TOKEN}" \
            -H "Content-Type: application/json" \
            --data-binary "$PAYLOAD" \
            "$URL/exec")
curl_pid=$!
while IFS= read -r line <&3; do
    case "$line" in
        '{"type":"stdout"'*)
            printf '%s' "$line" | jq -r '.data' 2>/dev/null | base64 -d 2>/dev/null
            ;;
        '{"type":"stderr"'*)
            printf '%s' "$line" | jq -r '.data' 2>/dev/null | base64 -d >&2 2>/dev/null
            ;;
        '{"type":"exit"'*)
            exit_code="$(printf '%s' "$line" | jq -r '.code' 2>/dev/null)"
            ;;
    esac
done
exec 3<&-
# Curl exits non-zero on connection failure (curl also writes its own message
# to stderr). Wait on the PID to read its TRUE exit, not the loop's.
wait "$curl_pid" 2>/dev/null
curl_rc=$?

# exit_code may be empty if the bridge is unreachable, the connection dropped
# mid-stream, or curl failed before any exit frame arrived. All collapse to the
# same "backend unreachable" 126 (the shim binary exists, but the host backend
# didn't deliver a result — distinct from 127 "command not found").
if [[ -z "$exit_code" ]]; then
    echo "${NAME}: host exec bridge unreachable or connection dropped: $URL" >&2
    echo "${NAME}: see ~/.config/construct-cli/logs/host_exec.log on the host." >&2
    exit 126
fi
exit "$exit_code"
