From 89257850cb3750eb48c3e25466352eefc0143e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:17:11 +0000 Subject: [PATCH 01/21] Add agent-friendly Beeper install script Prototype one-command installer that wires Beeper into the Edison Watch MCP gateway on macOS: installs prerequisites, brings up a headless Beeper Server, supervises the stdiod tunnel daemon, registers Beeper's stdio MCP proxy (npx @beeper/desktop-mcp) as a tunnel child, binds the Beeper access token, and prints the Edison MCP URL. Built to be driven by an agent or a human: every input is a flag or env var, missing inputs fail fast with the exact fix, subcommands follow a resource+verb pattern, --help carries examples, and --dry-run/--yes/--json are supported. Interactive prompts only run behind --interactive. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 423 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100755 scripts/install-beeper.sh diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh new file mode 100755 index 0000000..bf95ecc --- /dev/null +++ b/scripts/install-beeper.sh @@ -0,0 +1,423 @@ +#!/usr/bin/env bash +# +# install-beeper.sh - one-command installer that wires Beeper into the Edison +# Watch MCP gateway on macOS. +# +# It does five things, each idempotent: +# 1. Install prerequisites (node/npx, the `beeper` CLI, `edison-stdiod`). +# 2. Bring up a headless Beeper Server on 127.0.0.1:23373 (one OAuth click). +# 3. Log the stdiod daemon in to an Edison Watch account and supervise it. +# 4. Register Beeper's stdio MCP proxy (`npx @beeper/desktop-mcp`) as a +# tunnel child so the gateway can reach it. +# 5. Bind the Beeper access token so the child can authenticate, then print +# the Edison MCP URL the user hands to their AI client. +# +# Design note: this is a thin orchestrator over two CLIs (`beeper` and +# `edison-stdiod`) plus a couple of REST calls. It is built to be driven by an +# agent OR a human: every input is a flag or an env var, missing required +# inputs fail fast with the exact command to fix them, and nothing blocks on an +# interactive prompt unless you opt in with `--interactive`. +# +# Verified command surfaces (2026-07): +# beeper setup --server --install headless Beeper Server +# beeper accounts add link WhatsApp / Telegram / LinkedIn +# beeper auth email start|response non-browser email-code sign-in +# edison-stdiod login|install|status supervise the tunnel daemon +# edison-stdiod server add|list|remove register a stdio MCP child +# +# stdiod today supports the supervised daemon on macOS only. This script fails +# fast on other platforms and tells you so. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Defaults (every one overridable by flag or environment variable) +# --------------------------------------------------------------------------- +EW_BACKEND="${EW_BACKEND:-https://dashboard.edison.watch}" +EW_API_KEY="${EW_API_KEY:-}" # skip account bootstrap if set +BEEPER_ACCESS_TOKEN="${BEEPER_ACCESS_TOKEN:-}" # skip token minting if set +SERVER_NAME="${SERVER_NAME:-beeper}" # tunnel child name / gateway prefix +DEVICE_LABEL="${DEVICE_LABEL:-$(hostname -s 2>/dev/null || echo my-mac)}" +NETWORKS="${NETWORKS:-}" # comma list: whatsapp,telegram,linkedin +MCP_PKG="${MCP_PKG:-@beeper/desktop-mcp}" # the stdio proxy npx package + +DRY_RUN=0 +ASSUME_YES=0 +INTERACTIVE=0 +JSON=0 +INSTALL_DEPS=0 +VERBOSE=0 + +PROG="$(basename "$0")" + +# --------------------------------------------------------------------------- +# Output helpers (data to stdout, diagnostics to stderr) +# --------------------------------------------------------------------------- +log() { printf '%s\n' "$*" >&2; } +vlog() { [ "$VERBOSE" -eq 1 ] && printf 'debug: %s\n' "$*" >&2 || true; } +die() { printf 'error: %s\n' "$1" >&2; [ -n "${2:-}" ] && printf ' fix: %s\n' "$2" >&2; exit "${3:-1}"; } + +# run CMD... - echoes under --dry-run instead of executing. +run() { + if [ "$DRY_RUN" -eq 1 ]; then printf 'would run: %s\n' "$*" >&2; return 0; fi + vlog "run: $*" + "$@" +} + +# capture CMD... - like run but returns stdout; suppressed under --dry-run. +capture() { + if [ "$DRY_RUN" -eq 1 ]; then printf 'would run: %s\n' "$*" >&2; return 0; fi + "$@" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" "${2:-install $1 and retry}" +} + +confirm() { + [ "$ASSUME_YES" -eq 1 ] && return 0 + [ "$INTERACTIVE" -eq 0 ] && die "refusing to run a confirming action non-interactively: $1" \ + "pass --yes to proceed, or --dry-run to preview" + printf '%s [y/N] ' "$1" >&2; read -r ans; [ "$ans" = "y" ] || [ "$ans" = "Y" ] +} + +require_macos() { + [ "$(uname -s)" = "Darwin" ] || die "the stdiod daemon is macOS-only today" \ + "Linux and Windows support is on the roadmap; see stdiod/README.md" +} + +# --------------------------------------------------------------------------- +# Flag parsing (shared across subcommands; unknown flags fail fast) +# --------------------------------------------------------------------------- +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --ew-backend) EW_BACKEND="$2"; shift 2;; + --ew-api-key) EW_API_KEY="$2"; shift 2;; + --beeper-token) BEEPER_ACCESS_TOKEN="$2"; shift 2;; + --server-name) SERVER_NAME="$2"; shift 2;; + --device-label) DEVICE_LABEL="$2"; shift 2;; + --networks) NETWORKS="$2"; shift 2;; + --dry-run) DRY_RUN=1; shift;; + -y|--yes) ASSUME_YES=1; shift;; + --interactive) INTERACTIVE=1; shift;; + --install-deps) INSTALL_DEPS=1; shift;; + --json) JSON=1; shift;; + --verbose) VERBOSE=1; shift;; + -h|--help) return 10;; + --) shift; break;; + -*) die "unknown flag: $1" "run '$PROG --help' for accepted flags";; + *) ARGS+=("$1"); shift;; + esac + done +} + +# --------------------------------------------------------------------------- +# Step 1: prerequisites +# --------------------------------------------------------------------------- +ensure_deps() { + require_macos + + if ! command -v npx >/dev/null 2>&1; then + if [ "$INSTALL_DEPS" -eq 1 ]; then + need_cmd brew "install Homebrew from https://brew.sh, or install Node yourself" + run brew install node + else + die "npx (Node.js) not found; $MCP_PKG runs via npx" \ + "install Node (brew install node) or re-run with --install-deps" + fi + fi + + if ! command -v beeper >/dev/null 2>&1; then + if [ "$INSTALL_DEPS" -eq 1 ]; then + need_cmd brew "install Homebrew from https://brew.sh" + run brew install beeper/tap/cli + else + die "the 'beeper' CLI is not installed" \ + "run: brew install beeper/tap/cli (or re-run this with --install-deps)" + fi + fi + + if ! command -v edison-stdiod >/dev/null 2>&1; then + if [ "$INSTALL_DEPS" -eq 1 ]; then + need_cmd cargo "install a Rust toolchain from https://rustup.rs" + run cargo install --path "$(dirname "$0")/../crates/edison-stdiod" + else + die "the 'edison-stdiod' binary is not installed" \ + "run: cargo install --path crates/edison-stdiod (or re-run with --install-deps)" + fi + fi + log "deps ok: npx, beeper, edison-stdiod all present" +} + +# --------------------------------------------------------------------------- +# Step 2: headless Beeper Server +# --------------------------------------------------------------------------- +ensure_beeper_server() { + # `beeper status` exits 0 when a server target is adopted and reachable. + if beeper status >/dev/null 2>&1; then + log "beeper server: already running" + return 0 + fi + log "beeper server: installing headless server (a browser opens once to authorize your Beeper account)" + run beeper setup --server --install +} + +# --------------------------------------------------------------------------- +# Step 3: Beeper access token (for the stdio MCP proxy child) +# --------------------------------------------------------------------------- +# Precedence: explicit token > CLI-issued token > fail with the manual step. +ensure_beeper_token() { + if [ -n "$BEEPER_ACCESS_TOKEN" ]; then + log "beeper token: using supplied token" + return 0 + fi + # The CLI can mint a Desktop API token for approved connections without a + # browser once the server is authorized. If your CLI version exposes a + # different verb, pass the token in via --beeper-token / BEEPER_ACCESS_TOKEN. + local tok="" + tok="$(capture beeper api post /v0/access-tokens --json 2>/dev/null \ + | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" + if [ -n "$tok" ]; then + BEEPER_ACCESS_TOKEN="$tok" + log "beeper token: minted via CLI" + return 0 + fi + die "could not obtain a Beeper access token automatically" \ + "create one in Beeper > Settings > Developers > Approved connections, then re-run with --beeper-token " +} + +# --------------------------------------------------------------------------- +# Step 4a: Edison Watch account + API key +# --------------------------------------------------------------------------- +ensure_ew_api_key() { + if [ -n "$EW_API_KEY" ]; then + log "edison account: using supplied API key" + return 0 + fi + die "no Edison Watch API key provided" \ + "sign in at ${EW_BACKEND}, create an API key, then re-run with --ew-api-key ew_live_..." +} + +# --------------------------------------------------------------------------- +# Step 4b: supervise the tunnel daemon and register the Beeper child +# --------------------------------------------------------------------------- +wire_tunnel() { + run edison-stdiod login --backend "$EW_BACKEND" --api-key "$EW_API_KEY" --device-label "$DEVICE_LABEL" + run edison-stdiod install + + # Idempotent: only add the child if it is not already registered. + if edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + log "tunnel child '$SERVER_NAME': already registered" + else + run edison-stdiod server add "$SERVER_NAME" \ + --display-name "Beeper" \ + --command npx \ + --arg -y --arg "$MCP_PKG" + log "tunnel child '$SERVER_NAME': registered" + fi +} + +# --------------------------------------------------------------------------- +# Step 5: bind the Beeper token to the child +# --------------------------------------------------------------------------- +# `edison-stdiod server add` carries no env; the daemon receives per-child env +# from the backend (see stdiod env_store). We push BEEPER_ACCESS_TOKEN to the +# backend so it is stored as an Edison secret and injected at spawn. If the +# backend route is unavailable, we do not fail the whole install: the tunnel is +# up and the child is registered; only the token binding is pending, and we +# print the manual step. +bind_beeper_token() { + local path="${EW_SERVER_ENV_PATH:-/api/v1/servers/${SERVER_NAME}/env}" + local url="${EW_BACKEND}${path}" + if [ "$DRY_RUN" -eq 1 ]; then + printf 'would run: curl -sf -X POST %s (set BEEPER_ACCESS_TOKEN)\n' "$url" >&2 + return 0 + fi + local code + code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$url" \ + -H "Authorization: Bearer ${EW_API_KEY}" \ + -H "Content-Type: application/json" \ + --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || echo 000)" + case "$code" in + 2*) log "beeper token: bound to child '$SERVER_NAME' as an Edison secret";; + *) log "warning: could not bind the Beeper token via ${url} (http ${code})" + log " the tunnel and child are set up; bind the token manually in the Edison" + log " dashboard under Servers > ${SERVER_NAME} > environment, key BEEPER_ACCESS_TOKEN";; + esac +} + +# --------------------------------------------------------------------------- +# Chat networks +# --------------------------------------------------------------------------- +add_networks() { + [ -z "$NETWORKS" ] && return 0 + local IFS=',' + for net in $NETWORKS; do + net="$(printf '%s' "$net" | tr -d '[:space:]')" + [ -z "$net" ] && continue + log "network: adding '$net' (follow the QR / code prompt in this terminal)" + run beeper accounts add "$net" + done +} + +# --------------------------------------------------------------------------- +# Result +# --------------------------------------------------------------------------- +print_mcp_url() { + local mcp_url="${EW_BACKEND%/}/mcp" + local masked="${EW_API_KEY:0:10}..." + if [ "$JSON" -eq 1 ]; then + printf '{"mcp_url":"%s","auth_header":"Authorization: Bearer %s","server":"%s","device_label":"%s"}\n' \ + "$mcp_url" "$EW_API_KEY" "$SERVER_NAME" "$DEVICE_LABEL" + else + printf 'mcp_url: %s\n' "$mcp_url" + printf 'auth: Authorization: Bearer %s\n' "$masked" + printf 'server: %s (prefix: %s_*)\n' "$SERVER_NAME" "$SERVER_NAME" + printf 'device: %s\n' "$DEVICE_LABEL" + # Ready-to-run snippet uses the real key so it can be pasted as-is. + printf '\nclaude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" + fi +} + +# =========================================================================== +# Subcommands +# =========================================================================== +cmd_install() { + ensure_deps + ensure_beeper_server + ensure_beeper_token + ensure_ew_api_key + wire_tunnel + bind_beeper_token + add_networks + log "install complete." + print_mcp_url +} + +cmd_doctor() { + local ok=1 + for c in npx beeper edison-stdiod; do + if command -v "$c" >/dev/null 2>&1; then log "ok $c"; else log "MISS $c"; ok=0; fi + done + if beeper status >/dev/null 2>&1; then log "ok beeper server reachable"; else log "MISS beeper server"; ok=0; fi + if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod status >/dev/null 2>&1; then + log "ok stdiod daemon"; else log "MISS stdiod daemon (run: $PROG install)"; ok=0; fi + [ "$ok" -eq 1 ] && log "doctor: all good" || die "doctor: some checks failed (see above)" "$PROG install --install-deps" +} + +cmd_status() { + need_cmd edison-stdiod + run edison-stdiod status + command -v beeper >/dev/null 2>&1 && run beeper status || true +} + +cmd_network() { + local verb="${ARGS[0]:-}" + case "$verb" in + add) NETWORKS="${ARGS[1]:-}"; [ -z "$NETWORKS" ] && die "network name required" "$PROG network add whatsapp"; add_networks;; + list) run beeper accounts list;; + *) die "unknown 'network' verb: ${verb:-}" "$PROG network add whatsapp | $PROG network list";; + esac +} + +cmd_mcp_url() { ensure_ew_api_key; print_mcp_url; } + +cmd_uninstall() { + confirm "remove the stdiod supervisor unit and Beeper child?" || die "aborted" "" + command -v edison-stdiod >/dev/null 2>&1 && { + run edison-stdiod server remove "$SERVER_NAME" || true + run edison-stdiod uninstall + } + log "uninstall complete. Beeper Server was left running; remove it with: beeper uninstall server" +} + +# =========================================================================== +# Help +# =========================================================================== +usage() { + cat >&2 < [flags] + +Commands: + install Full flow: deps, Beeper Server, tunnel, register + bind Beeper, print MCP URL + doctor Check prerequisites and current state (read-only) + status Show stdiod daemon and Beeper Server status + network add Link a chat network (whatsapp | telegram | linkedin | ...) + network list List linked chat networks + mcp-url Print the Edison MCP URL and client snippet + uninstall Remove the tunnel child and supervisor unit + +Common flags (also settable as UPPER_SNAKE env vars): + --ew-backend URL Edison backend (EW_BACKEND, default $EW_BACKEND) + --ew-api-key KEY Edison API key (EW_API_KEY) required for install/mcp-url + --beeper-token TOK Beeper access token (BEEPER_ACCESS_TOKEN) skips CLI minting + --networks a,b,c Link these after wiring (NETWORKS) + --install-deps Auto-install npx/beeper/edison-stdiod via brew/cargo + --dry-run Print what would run; change nothing + --yes Skip confirmations (agents pass this) + --interactive Allow interactive prompts as a fallback + --json Machine-readable output where supported + --verbose Debug logging on stderr + -h, --help This help + +Examples: + # Non-interactive, agent-friendly: everything supplied up front + $PROG install --install-deps --yes \\ + --ew-api-key ew_live_abc --beeper-token bpr_xyz \\ + --networks whatsapp,telegram,linkedin + + # Preview without changing anything + $PROG install --ew-api-key ew_live_abc --dry-run + + # Link WhatsApp later, then fetch the URL as JSON for a config file + $PROG network add whatsapp + $PROG mcp-url --ew-api-key ew_live_abc --json + +Exit codes: 0 ok, 1 error (message + fix printed to stderr). +EOF +} + +subcmd_help() { + case "$1" in + install) log "install - run the full wiring flow. Idempotent; safe to re-run." + log " needs: --ew-api-key (or EW_API_KEY). Optional: --beeper-token, --networks, --install-deps, --yes, --dry-run." + log " example: $PROG install --install-deps --yes --ew-api-key ew_live_abc --networks whatsapp";; + network) log "network add | network list" + log " example: $PROG network add telegram";; + mcp-url) log "mcp-url - print the gateway URL + client snippet. needs --ew-api-key. supports --json.";; + status) log "status - show stdiod daemon + Beeper Server status.";; + doctor) log "doctor - verify prerequisites and current state (read-only).";; + uninstall)log "uninstall - remove the tunnel child and supervisor unit. pass --yes to skip the prompt.";; + *) usage;; + esac +} + +# =========================================================================== +# Dispatch +# =========================================================================== +main() { + local cmd="${1:-}"; shift || true + ARGS=() + # Support "network add" as a two-word command. + if [ "$cmd" = "network" ]; then + ARGS+=("${1:-}"); shift || true + fi + parse_flags "$@" || { subcmd_help "$cmd"; exit 0; } + + case "$cmd" in + install) cmd_install;; + doctor) cmd_doctor;; + status) cmd_status;; + network) cmd_network;; + mcp-url) cmd_mcp_url;; + uninstall) cmd_uninstall;; + ""|help|-h|--help) usage;; + *) die "unknown command: $cmd" "run '$PROG --help' for the command list";; + esac +} + +main "$@" From 15026ff5929b5dfb58e0cba0dcf0e455dcb45ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:28:00 +0000 Subject: [PATCH 02/21] Fix install-beeper.sh bugs found by running it end-to-end Ran the script on Linux against the real edison-stdiod binary (built from this checkout) plus stubbed beeper, which surfaced several defects: - add_networks leaked IFS=',' into run()'s "$*" logging, so --dry-run previewed 'beeper accounts add' calls with commas instead of spaces. Split on commas via tr without mutating IFS. - Token-bind curl printed 'http 000000' (its own 000 plus a '|| echo 000' fallback) and had no timeout. Use '|| true' with an empty-check and add --connect-timeout/-m so it fails fast. - --dry-run without --beeper-token aborted at the token step instead of previewing. Add a dry-run branch that reports the intent and continues. - edison-stdiod install failures spilled a raw anyhow backtrace and aborted mid-flow via set -e. Export RUST_BACKTRACE/RUST_LIB_BACKTRACE=0 and wrap each edison-stdiod step so failures produce an actionable message. - Skip the live 'server list' idempotency probe under --dry-run. - The real binary carries a Linux (systemd --user) path, so the hard macOS-only guard was stricter than the daemon. Warn on Linux and let the daemon report capability, block only truly unsupported platforms. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 63 ++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index bf95ecc..c78bb66 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -30,6 +30,11 @@ set -euo pipefail +# Keep edison-stdiod (anyhow) from spilling a Rust backtrace on expected +# failures; we translate its exit codes into actionable messages ourselves. +export RUST_BACKTRACE="${RUST_BACKTRACE:-0}" +export RUST_LIB_BACKTRACE="${RUST_LIB_BACKTRACE:-0}" + # --------------------------------------------------------------------------- # Defaults (every one overridable by flag or environment variable) # --------------------------------------------------------------------------- @@ -81,9 +86,15 @@ confirm() { printf '%s [y/N] ' "$1" >&2; read -r ans; [ "$ans" = "y" ] || [ "$ans" = "Y" ] } -require_macos() { - [ "$(uname -s)" = "Darwin" ] || die "the stdiod daemon is macOS-only today" \ - "Linux and Windows support is on the roadmap; see stdiod/README.md" +# macOS is the supported target. The binary also carries a Linux (systemd +# --user) path, so we allow Linux with a warning instead of blocking, and let +# `edison-stdiod install` report any capability gap itself. Windows is out. +require_supported_platform() { + case "$(uname -s)" in + Darwin) ;; + Linux) log "warning: Linux support in edison-stdiod is experimental (needs a systemd --user session); macOS is the supported target";; + *) die "unsupported platform: $(uname -s)" "macOS is supported; Linux is experimental; see stdiod/README.md";; + esac } # --------------------------------------------------------------------------- @@ -116,7 +127,7 @@ parse_flags() { # Step 1: prerequisites # --------------------------------------------------------------------------- ensure_deps() { - require_macos + require_supported_platform if ! command -v npx >/dev/null 2>&1; then if [ "$INSTALL_DEPS" -eq 1 ]; then @@ -172,6 +183,11 @@ ensure_beeper_token() { log "beeper token: using supplied token" return 0 fi + if [ "$DRY_RUN" -eq 1 ]; then + log "beeper token: would mint via CLI (or require --beeper-token)" + BEEPER_ACCESS_TOKEN="dry-run-placeholder-token" + return 0 + fi # The CLI can mint a Desktop API token for approved connections without a # browser once the server is authorized. If your CLI version exposes a # different verb, pass the token in via --beeper-token / BEEPER_ACCESS_TOKEN. @@ -203,17 +219,28 @@ ensure_ew_api_key() { # Step 4b: supervise the tunnel daemon and register the Beeper child # --------------------------------------------------------------------------- wire_tunnel() { - run edison-stdiod login --backend "$EW_BACKEND" --api-key "$EW_API_KEY" --device-label "$DEVICE_LABEL" - run edison-stdiod install + # Each step is wrapped so a failure yields a clean, actionable message + # instead of a raw daemon backtrace plus a set -e abort mid-flow. + if ! run edison-stdiod login --backend "$EW_BACKEND" --api-key "$EW_API_KEY" --device-label "$DEVICE_LABEL"; then + die "edison-stdiod login failed" "check --ew-backend and --ew-api-key, then re-run: $PROG install" + fi + if ! run edison-stdiod install; then + die "edison-stdiod install could not register the supervisor unit" \ + "macOS needs no privileges; Linux needs a logged-in systemd --user session. Fix that, then re-run: $PROG install" + fi - # Idempotent: only add the child if it is not already registered. - if edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + # Idempotent: only add the child if it is not already registered. The live + # probe is skipped under --dry-run (nothing is registered to probe). + if [ "$DRY_RUN" -eq 0 ] && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then log "tunnel child '$SERVER_NAME': already registered" else - run edison-stdiod server add "$SERVER_NAME" \ - --display-name "Beeper" \ - --command npx \ - --arg -y --arg "$MCP_PKG" + if ! run edison-stdiod server add "$SERVER_NAME" \ + --display-name "Beeper" \ + --command npx \ + --arg -y --arg "$MCP_PKG"; then + die "edison-stdiod server add failed for '$SERVER_NAME'" \ + "confirm the daemon is logged in and the backend is reachable, then re-run: $PROG install" + fi log "tunnel child '$SERVER_NAME': registered" fi } @@ -235,10 +262,11 @@ bind_beeper_token() { return 0 fi local code - code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$url" \ + code="$(curl -s -o /dev/null -w '%{http_code}' -m 15 --connect-timeout 5 -X POST "$url" \ -H "Authorization: Bearer ${EW_API_KEY}" \ -H "Content-Type: application/json" \ - --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || echo 000)" + --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || true)" + [ -z "$code" ] && code="000" case "$code" in 2*) log "beeper token: bound to child '$SERVER_NAME' as an Edison secret";; *) log "warning: could not bind the Beeper token via ${url} (http ${code})" @@ -252,9 +280,10 @@ bind_beeper_token() { # --------------------------------------------------------------------------- add_networks() { [ -z "$NETWORKS" ] && return 0 - local IFS=',' - for net in $NETWORKS; do - net="$(printf '%s' "$net" | tr -d '[:space:]')" + # Split on commas without leaking IFS into run()'s "$*" logging. + local net nets + nets="$(printf '%s' "$NETWORKS" | tr ',' ' ')" + for net in $nets; do [ -z "$net" ] && continue log "network: adding '$net' (follow the QR / code prompt in this terminal)" run beeper accounts add "$net" From 562a08405fdc6f405f10b4c93ea1f675e89d7dca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:15:01 +0000 Subject: [PATCH 03/21] install-beeper: validate + confirm dependency auto-install Reported: 'install --dry-run' died on a missing 'beeper' CLI instead of previewing. Rework dependency handling around a single ensure_tool helper: - --dry-run previews the install command for each missing dep and never fails, so you can inspect the whole plan before installing anything. - Auto-install now requires consent (--install-deps or --interactive) AND a confirmation (auto-passed by --yes, prompted under --interactive, refused non-interactively without --yes). - After running an installer, validate the command actually landed on PATH; validate the installer itself (brew/cargo) exists before invoking it. - No consent still fails fast with the exact manual command. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 81 +++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index c78bb66..69069e8 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -126,39 +126,62 @@ parse_flags() { # --------------------------------------------------------------------------- # Step 1: prerequisites # --------------------------------------------------------------------------- -ensure_deps() { - require_supported_platform +# +# ensure_tool +# +# Guarantees is on PATH, or explains exactly how to get it. Behavior: +# - already present -> no-op +# - --dry-run -> preview the install command, never fail +# - no consent to auto-install -> fail fast with +# (consent = --install-deps, or an --interactive session) +# - consent given -> confirm (auto-passed by --yes), run the +# installer, then VALIDATE the command actually landed on PATH +ensure_tool() { + local cmd="$1" fix="$2"; shift 2 + command -v "$cmd" >/dev/null 2>&1 && return 0 - if ! command -v npx >/dev/null 2>&1; then - if [ "$INSTALL_DEPS" -eq 1 ]; then - need_cmd brew "install Homebrew from https://brew.sh, or install Node yourself" - run brew install node - else - die "npx (Node.js) not found; $MCP_PKG runs via npx" \ - "install Node (brew install node) or re-run with --install-deps" - fi + if [ "$DRY_RUN" -eq 1 ]; then + log "dep '$cmd' missing; would install via: $*" + return 0 fi - if ! command -v beeper >/dev/null 2>&1; then - if [ "$INSTALL_DEPS" -eq 1 ]; then - need_cmd brew "install Homebrew from https://brew.sh" - run brew install beeper/tap/cli - else - die "the 'beeper' CLI is not installed" \ - "run: brew install beeper/tap/cli (or re-run this with --install-deps)" - fi + # No consent to auto-install at all: fail fast with the manual command. + if [ "$INSTALL_DEPS" -eq 0 ] && [ "$INTERACTIVE" -eq 0 ]; then + die "'$cmd' is not installed" "$fix" fi - if ! command -v edison-stdiod >/dev/null 2>&1; then - if [ "$INSTALL_DEPS" -eq 1 ]; then - need_cmd cargo "install a Rust toolchain from https://rustup.rs" - run cargo install --path "$(dirname "$0")/../crates/edison-stdiod" - else - die "the 'edison-stdiod' binary is not installed" \ - "run: cargo install --path crates/edison-stdiod (or re-run with --install-deps)" - fi + # Consent exists; confirm intent (confirm() auto-passes with --yes, prompts + # under --interactive, and refuses non-interactively without --yes). + confirm "'$cmd' is missing. Install it now via: $*" \ + || die "declined; '$cmd' not installed" "$fix" + + # Validate the installer itself is available before invoking it. + command -v "$1" >/dev/null 2>&1 || die "cannot auto-install '$cmd': '$1' not found" "$fix" + + log "installing '$cmd' via: $*" + "$@" || die "auto-install of '$cmd' failed" "$fix" + command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' still not on PATH after install" "$fix" + log "installed '$cmd'" +} + +ensure_deps() { + require_supported_platform + local stdiod_src; stdiod_src="$(dirname "$0")/../crates/edison-stdiod" + ensure_tool npx \ + "install Node (brew install node) or re-run with --install-deps" \ + brew install node + ensure_tool beeper \ + "run: brew install beeper/tap/cli (or re-run with --install-deps)" \ + brew install beeper/tap/cli + ensure_tool edison-stdiod \ + "run: cargo install --path crates/edison-stdiod (or re-run with --install-deps)" \ + cargo install --path "$stdiod_src" + + if [ "$DRY_RUN" -eq 1 ]; then + log "deps: preview only (nothing was installed)" + else + log "deps ok: npx, beeper, edison-stdiod all present" fi - log "deps ok: npx, beeper, edison-stdiod all present" } # --------------------------------------------------------------------------- @@ -385,7 +408,9 @@ Common flags (also settable as UPPER_SNAKE env vars): --ew-api-key KEY Edison API key (EW_API_KEY) required for install/mcp-url --beeper-token TOK Beeper access token (BEEPER_ACCESS_TOKEN) skips CLI minting --networks a,b,c Link these after wiring (NETWORKS) - --install-deps Auto-install npx/beeper/edison-stdiod via brew/cargo + --install-deps Consent to auto-install missing deps (npx/beeper/edison-stdiod + via brew/cargo). Confirms first unless --yes; validates each + landed on PATH. --dry-run previews installs without running them. --dry-run Print what would run; change nothing --yes Skip confirmations (agents pass this) --interactive Allow interactive prompts as a fallback From 84daf87fbd74553f945630fca537e842da05d3c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:21:54 +0000 Subject: [PATCH 04/21] install-beeper: confirm token-bind endpoint, add status-aware diagnostics Traced the real backend route: POST /api/v1/servers/{name}/env (UpdateServerEnvRequest) stages the value in the device env_store and respawns the child. The script's guessed path/body were already correct. - Drop the 'guessed route' hedging; document the confirmed endpoint. - The endpoint is admin-only, so surface a clear 401/403 message telling the user --ew-api-key must belong to an org admin. - Distinguish 000 (unreachable / daemon not yet connected) from other codes, each with an actionable next step; keep the failure non-fatal. - Bump the bind timeout to 30s since the call blocks on a child respawn. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 69069e8..eb5a9be 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -271,30 +271,38 @@ wire_tunnel() { # --------------------------------------------------------------------------- # Step 5: bind the Beeper token to the child # --------------------------------------------------------------------------- -# `edison-stdiod server add` carries no env; the daemon receives per-child env -# from the backend (see stdiod env_store). We push BEEPER_ACCESS_TOKEN to the -# backend so it is stored as an Edison secret and injected at spawn. If the -# backend route is unavailable, we do not fail the whole install: the tunnel is -# up and the child is registered; only the token binding is pending, and we -# print the manual step. +# `edison-stdiod server add` carries no env, so we push BEEPER_ACCESS_TOKEN +# separately. The route is the backend's confirmed stdio_tunnel env endpoint, +# `POST /api/v1/servers/{name}/env` (schema UpdateServerEnvRequest): the value +# is staged in the device's on-device env_store and the child is respawned with +# it. The endpoint is admin-only, so --ew-api-key must belong to an org admin. +# A failure here is non-fatal: the tunnel and child are already set up, so we +# print the manual step and let the rest of the install finish. bind_beeper_token() { local path="${EW_SERVER_ENV_PATH:-/api/v1/servers/${SERVER_NAME}/env}" local url="${EW_BACKEND}${path}" if [ "$DRY_RUN" -eq 1 ]; then - printf 'would run: curl -sf -X POST %s (set BEEPER_ACCESS_TOKEN)\n' "$url" >&2 + printf 'would run: curl -X POST %s (set BEEPER_ACCESS_TOKEN, respawns child)\n' "$url" >&2 return 0 fi local code - code="$(curl -s -o /dev/null -w '%{http_code}' -m 15 --connect-timeout 5 -X POST "$url" \ + code="$(curl -s -o /dev/null -w '%{http_code}' -m 30 --connect-timeout 5 -X POST "$url" \ -H "Authorization: Bearer ${EW_API_KEY}" \ -H "Content-Type: application/json" \ --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || true)" [ -z "$code" ] && code="000" case "$code" in - 2*) log "beeper token: bound to child '$SERVER_NAME' as an Edison secret";; - *) log "warning: could not bind the Beeper token via ${url} (http ${code})" - log " the tunnel and child are set up; bind the token manually in the Edison" - log " dashboard under Servers > ${SERVER_NAME} > environment, key BEEPER_ACCESS_TOKEN";; + 2*) log "beeper token: bound to child '$SERVER_NAME' and respawned";; + 401|403) log "warning: not authorized to bind the token (http ${code})" + log " the ${url##*/api/} endpoint is admin-only; --ew-api-key must belong to an org admin";; + 000) log "warning: could not reach ${url} (network, or daemon not connected yet)" + log " confirm 'edison-stdiod status' shows connected, then re-run: $PROG install";; + *) log "warning: token bind returned http ${code} for ${url}";; + esac + case "$code" in + 2*) ;; + *) log " manual fallback: set BEEPER_ACCESS_TOKEN for server '${SERVER_NAME}' in the" + log " Edison dashboard under Servers > ${SERVER_NAME} > environment";; esac } From 52d0295c552d0bb0c1d8347c75c10f9a762d0ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:30:56 +0000 Subject: [PATCH 05/21] install-beeper: colorized, grouped progress output Add a TTY-aware color layer with semantic helpers (step/ok/warn/info) so both dry-run and real runs read as grouped phases instead of a wall of white text: - Colors auto-disable when stderr is not a TTY, when NO_COLOR is set, or with the new --no-color flag, so piped and agent output stays a clean, parseable ASCII stream (verified: no escape codes leak when piped). - Phase headers (>>), success (+), info (-), warn (!), and error (x) markers. - Result block on stdout is gated separately on stdout being a TTY, and the copy-paste 'claude mcp add' line stays uncolored so it pastes cleanly. - --json output is unchanged and never colorized. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 129 ++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 48 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index eb5a9be..7de0ddf 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -52,26 +52,49 @@ INTERACTIVE=0 JSON=0 INSTALL_DEPS=0 VERBOSE=0 +NO_COLOR_FLAG=0 PROG="$(basename "$0")" # --------------------------------------------------------------------------- -# Output helpers (data to stdout, diagnostics to stderr) +# Colors (auto-off when stderr is not a TTY, when NO_COLOR is set, or with +# --no-color, so piped and agent output stays a clean, parseable stream) +# --------------------------------------------------------------------------- +C_RESET=; C_BOLD=; C_DIM=; C_RED=; C_GREEN=; C_YELLOW=; C_BLUE=; C_CYAN=; C_GREY= +init_colors() { + if [ "$NO_COLOR_FLAG" -eq 1 ] || [ -n "${NO_COLOR:-}" ] || [ ! -t 2 ] || [ "${TERM:-}" = "dumb" ]; then + return 0 + fi + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' + C_BLUE=$'\033[34m'; C_CYAN=$'\033[36m'; C_GREY=$'\033[90m' +} + +# --------------------------------------------------------------------------- +# Output helpers (data to stdout, diagnostics + progress to stderr) # --------------------------------------------------------------------------- log() { printf '%s\n' "$*" >&2; } -vlog() { [ "$VERBOSE" -eq 1 ] && printf 'debug: %s\n' "$*" >&2 || true; } -die() { printf 'error: %s\n' "$1" >&2; [ -n "${2:-}" ] && printf ' fix: %s\n' "$2" >&2; exit "${3:-1}"; } +step() { printf '%s%s>>%s %s%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET" "$C_BOLD" "$*$C_RESET" >&2; } +ok() { printf ' %s+%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2; } +info() { printf ' %s-%s %s%s%s\n' "$C_GREY" "$C_RESET" "$C_DIM" "$*" "$C_RESET" >&2; } +warn() { printf ' %s!%s %s%s%s\n' "$C_YELLOW" "$C_RESET" "$C_YELLOW" "$*" "$C_RESET" >&2; } +vlog() { [ "$VERBOSE" -eq 1 ] && printf ' %sdebug: %s%s\n' "$C_GREY" "$*" "$C_RESET" >&2 || true; } +die() { + printf '%s%sx error:%s %s\n' "$C_BOLD" "$C_RED" "$C_RESET" "$1" >&2 + [ -n "${2:-}" ] && printf ' %sfix:%s %s\n' "$C_CYAN" "$C_RESET" "$2" >&2 + exit "${3:-1}" +} -# run CMD... - echoes under --dry-run instead of executing. +# run CMD... - previews under --dry-run instead of executing. run() { - if [ "$DRY_RUN" -eq 1 ]; then printf 'would run: %s\n' "$*" >&2; return 0; fi + if [ "$DRY_RUN" -eq 1 ]; then printf ' %swould run:%s %s%s%s\n' "$C_CYAN" "$C_RESET" "$C_DIM" "$*" "$C_RESET" >&2; return 0; fi vlog "run: $*" "$@" } # capture CMD... - like run but returns stdout; suppressed under --dry-run. capture() { - if [ "$DRY_RUN" -eq 1 ]; then printf 'would run: %s\n' "$*" >&2; return 0; fi + if [ "$DRY_RUN" -eq 1 ]; then printf ' %swould run:%s %s%s%s\n' "$C_CYAN" "$C_RESET" "$C_DIM" "$*" "$C_RESET" >&2; return 0; fi "$@" } @@ -92,7 +115,7 @@ confirm() { require_supported_platform() { case "$(uname -s)" in Darwin) ;; - Linux) log "warning: Linux support in edison-stdiod is experimental (needs a systemd --user session); macOS is the supported target";; + Linux) warn "Linux support in edison-stdiod is experimental (needs a systemd --user session); macOS is the supported target";; *) die "unsupported platform: $(uname -s)" "macOS is supported; Linux is experimental; see stdiod/README.md";; esac } @@ -113,6 +136,7 @@ parse_flags() { -y|--yes) ASSUME_YES=1; shift;; --interactive) INTERACTIVE=1; shift;; --install-deps) INSTALL_DEPS=1; shift;; + --no-color) NO_COLOR_FLAG=1; shift;; --json) JSON=1; shift;; --verbose) VERBOSE=1; shift;; -h|--help) return 10;; @@ -141,7 +165,7 @@ ensure_tool() { command -v "$cmd" >/dev/null 2>&1 && return 0 if [ "$DRY_RUN" -eq 1 ]; then - log "dep '$cmd' missing; would install via: $*" + info "dep '$cmd' missing; would install via: $*" return 0 fi @@ -158,13 +182,14 @@ ensure_tool() { # Validate the installer itself is available before invoking it. command -v "$1" >/dev/null 2>&1 || die "cannot auto-install '$cmd': '$1' not found" "$fix" - log "installing '$cmd' via: $*" + step "installing '$cmd' via: $*" "$@" || die "auto-install of '$cmd' failed" "$fix" command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' still not on PATH after install" "$fix" - log "installed '$cmd'" + ok "installed '$cmd'" } ensure_deps() { + step "Checking prerequisites" require_supported_platform local stdiod_src; stdiod_src="$(dirname "$0")/../crates/edison-stdiod" ensure_tool npx \ @@ -178,9 +203,9 @@ ensure_deps() { cargo install --path "$stdiod_src" if [ "$DRY_RUN" -eq 1 ]; then - log "deps: preview only (nothing was installed)" + info "deps: preview only (nothing was installed)" else - log "deps ok: npx, beeper, edison-stdiod all present" + ok "npx, beeper, edison-stdiod all present" fi } @@ -188,12 +213,13 @@ ensure_deps() { # Step 2: headless Beeper Server # --------------------------------------------------------------------------- ensure_beeper_server() { + step "Beeper Server (headless)" # `beeper status` exits 0 when a server target is adopted and reachable. - if beeper status >/dev/null 2>&1; then - log "beeper server: already running" + if [ "$DRY_RUN" -eq 0 ] && beeper status >/dev/null 2>&1; then + ok "already running" return 0 fi - log "beeper server: installing headless server (a browser opens once to authorize your Beeper account)" + info "installing headless server (a browser opens once to authorize your Beeper account)" run beeper setup --server --install } @@ -202,12 +228,13 @@ ensure_beeper_server() { # --------------------------------------------------------------------------- # Precedence: explicit token > CLI-issued token > fail with the manual step. ensure_beeper_token() { + step "Beeper access token" if [ -n "$BEEPER_ACCESS_TOKEN" ]; then - log "beeper token: using supplied token" + ok "using supplied token" return 0 fi if [ "$DRY_RUN" -eq 1 ]; then - log "beeper token: would mint via CLI (or require --beeper-token)" + info "would mint via CLI (or require --beeper-token)" BEEPER_ACCESS_TOKEN="dry-run-placeholder-token" return 0 fi @@ -219,7 +246,7 @@ ensure_beeper_token() { | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" if [ -n "$tok" ]; then BEEPER_ACCESS_TOKEN="$tok" - log "beeper token: minted via CLI" + ok "minted via CLI" return 0 fi die "could not obtain a Beeper access token automatically" \ @@ -231,7 +258,7 @@ ensure_beeper_token() { # --------------------------------------------------------------------------- ensure_ew_api_key() { if [ -n "$EW_API_KEY" ]; then - log "edison account: using supplied API key" + ok "edison account: using supplied API key" return 0 fi die "no Edison Watch API key provided" \ @@ -242,6 +269,7 @@ ensure_ew_api_key() { # Step 4b: supervise the tunnel daemon and register the Beeper child # --------------------------------------------------------------------------- wire_tunnel() { + step "Edison tunnel (stdiod daemon)" # Each step is wrapped so a failure yields a clean, actionable message # instead of a raw daemon backtrace plus a set -e abort mid-flow. if ! run edison-stdiod login --backend "$EW_BACKEND" --api-key "$EW_API_KEY" --device-label "$DEVICE_LABEL"; then @@ -255,7 +283,7 @@ wire_tunnel() { # Idempotent: only add the child if it is not already registered. The live # probe is skipped under --dry-run (nothing is registered to probe). if [ "$DRY_RUN" -eq 0 ] && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then - log "tunnel child '$SERVER_NAME': already registered" + ok "tunnel child '$SERVER_NAME' already registered" else if ! run edison-stdiod server add "$SERVER_NAME" \ --display-name "Beeper" \ @@ -264,7 +292,7 @@ wire_tunnel() { die "edison-stdiod server add failed for '$SERVER_NAME'" \ "confirm the daemon is logged in and the backend is reachable, then re-run: $PROG install" fi - log "tunnel child '$SERVER_NAME': registered" + ok "tunnel child '$SERVER_NAME' registered" fi } @@ -279,10 +307,11 @@ wire_tunnel() { # A failure here is non-fatal: the tunnel and child are already set up, so we # print the manual step and let the rest of the install finish. bind_beeper_token() { + step "Binding Beeper token to the tunnel child" local path="${EW_SERVER_ENV_PATH:-/api/v1/servers/${SERVER_NAME}/env}" local url="${EW_BACKEND}${path}" if [ "$DRY_RUN" -eq 1 ]; then - printf 'would run: curl -X POST %s (set BEEPER_ACCESS_TOKEN, respawns child)\n' "$url" >&2 + run curl -X POST "$url" "(set BEEPER_ACCESS_TOKEN, respawns child)" return 0 fi local code @@ -292,18 +321,14 @@ bind_beeper_token() { --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || true)" [ -z "$code" ] && code="000" case "$code" in - 2*) log "beeper token: bound to child '$SERVER_NAME' and respawned";; - 401|403) log "warning: not authorized to bind the token (http ${code})" - log " the ${url##*/api/} endpoint is admin-only; --ew-api-key must belong to an org admin";; - 000) log "warning: could not reach ${url} (network, or daemon not connected yet)" - log " confirm 'edison-stdiod status' shows connected, then re-run: $PROG install";; - *) log "warning: token bind returned http ${code} for ${url}";; - esac - case "$code" in - 2*) ;; - *) log " manual fallback: set BEEPER_ACCESS_TOKEN for server '${SERVER_NAME}' in the" - log " Edison dashboard under Servers > ${SERVER_NAME} > environment";; + 2*) ok "bound to child '$SERVER_NAME' and respawned"; return 0;; + 401|403) warn "not authorized to bind the token (http ${code})" + warn "the env endpoint is admin-only; --ew-api-key must belong to an org admin";; + 000) warn "could not reach ${url} (network, or daemon not connected yet)" + warn "confirm 'edison-stdiod status' shows connected, then re-run: $PROG install";; + *) warn "token bind returned http ${code} for ${url}";; esac + warn "manual fallback: set BEEPER_ACCESS_TOKEN for server '${SERVER_NAME}' in the Edison dashboard under Servers > ${SERVER_NAME} > environment" } # --------------------------------------------------------------------------- @@ -311,12 +336,13 @@ bind_beeper_token() { # --------------------------------------------------------------------------- add_networks() { [ -z "$NETWORKS" ] && return 0 + step "Linking chat networks" # Split on commas without leaking IFS into run()'s "$*" logging. local net nets nets="$(printf '%s' "$NETWORKS" | tr ',' ' ')" for net in $nets; do [ -z "$net" ] && continue - log "network: adding '$net' (follow the QR / code prompt in this terminal)" + info "adding '$net' (follow the QR / code prompt in this terminal)" run beeper accounts add "$net" done } @@ -330,14 +356,18 @@ print_mcp_url() { if [ "$JSON" -eq 1 ]; then printf '{"mcp_url":"%s","auth_header":"Authorization: Bearer %s","server":"%s","device_label":"%s"}\n' \ "$mcp_url" "$EW_API_KEY" "$SERVER_NAME" "$DEVICE_LABEL" - else - printf 'mcp_url: %s\n' "$mcp_url" - printf 'auth: Authorization: Bearer %s\n' "$masked" - printf 'server: %s (prefix: %s_*)\n' "$SERVER_NAME" "$SERVER_NAME" - printf 'device: %s\n' "$DEVICE_LABEL" - # Ready-to-run snippet uses the real key so it can be pasted as-is. - printf '\nclaude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" + return 0 fi + # stdout-gated colors so redirected/piped output stays clean and parseable. + local b=$C_BOLD g=$C_GREEN d=$C_DIM r=$C_RESET + [ -t 1 ] || { b=; g=; d=; r=; } + printf '%smcp_url:%s %s%s%s\n' "$b" "$r" "$g" "$mcp_url" "$r" + printf '%sauth:%s Authorization: Bearer %s\n' "$b" "$r" "$masked" + printf '%sserver:%s %s (prefix: %s_*)\n' "$b" "$r" "$SERVER_NAME" "$SERVER_NAME" + printf '%sdevice:%s %s\n' "$b" "$r" "$DEVICE_LABEL" + # Ready-to-run snippet uses the real key so it can be pasted as-is (uncolored). + printf '\n%s# add to Claude Code:%s\n' "$d" "$r" + printf 'claude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" } # =========================================================================== @@ -351,19 +381,20 @@ cmd_install() { wire_tunnel bind_beeper_token add_networks - log "install complete." + printf '\n%s%s== install complete ==%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" >&2 print_mcp_url } cmd_doctor() { - local ok=1 + step "Doctor" + local allgood=1 for c in npx beeper edison-stdiod; do - if command -v "$c" >/dev/null 2>&1; then log "ok $c"; else log "MISS $c"; ok=0; fi + if command -v "$c" >/dev/null 2>&1; then ok "$c"; else warn "$c missing"; allgood=0; fi done - if beeper status >/dev/null 2>&1; then log "ok beeper server reachable"; else log "MISS beeper server"; ok=0; fi + if beeper status >/dev/null 2>&1; then ok "beeper server reachable"; else warn "beeper server not reachable"; allgood=0; fi if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod status >/dev/null 2>&1; then - log "ok stdiod daemon"; else log "MISS stdiod daemon (run: $PROG install)"; ok=0; fi - [ "$ok" -eq 1 ] && log "doctor: all good" || die "doctor: some checks failed (see above)" "$PROG install --install-deps" + ok "stdiod daemon connected"; else warn "stdiod daemon not running (run: $PROG install)"; allgood=0; fi + [ "$allgood" -eq 1 ] && ok "all good" || die "some checks failed (see above)" "$PROG install --install-deps" } cmd_status() { @@ -423,6 +454,7 @@ Common flags (also settable as UPPER_SNAKE env vars): --yes Skip confirmations (agents pass this) --interactive Allow interactive prompts as a fallback --json Machine-readable output where supported + --no-color Disable colored output (also honors NO_COLOR) --verbose Debug logging on stderr -h, --help This help @@ -468,7 +500,8 @@ main() { if [ "$cmd" = "network" ]; then ARGS+=("${1:-}"); shift || true fi - parse_flags "$@" || { subcmd_help "$cmd"; exit 0; } + parse_flags "$@" || { init_colors; subcmd_help "$cmd"; exit 0; } + init_colors case "$cmd" in install) cmd_install;; From 6837f522fb53bbc6355a489327621eff45ab03e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:08:13 +0000 Subject: [PATCH 06/21] install-beeper: quiet brew, accurate headless-token guidance From a real macOS run: - brew install dumped its full auto-update 'New Formulae' wall. Export HOMEBREW_NO_AUTO_UPDATE=1 and HOMEBREW_NO_ENV_HINTS=1 and pass --quiet so dependency installs are calm. - The token step optimistically tried a nonexistent mint endpoint and then pointed at a GUI-only path. Beeper exposes no headless token mint, so state that plainly: guide the user to create a token once in the app UI and re-run with --beeper-token, noting the re-run is fast because deps and the Beeper Server are already set up and every step is idempotent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 7de0ddf..9d05520 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -35,6 +35,11 @@ set -euo pipefail export RUST_BACKTRACE="${RUST_BACKTRACE:-0}" export RUST_LIB_BACKTRACE="${RUST_LIB_BACKTRACE:-0}" +# Keep `brew install` from dumping its auto-update "New Formulae" wall and env +# hints on every run. Respected only by Homebrew; harmless elsewhere. +export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" +export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" + # --------------------------------------------------------------------------- # Defaults (every one overridable by flag or environment variable) # --------------------------------------------------------------------------- @@ -194,10 +199,10 @@ ensure_deps() { local stdiod_src; stdiod_src="$(dirname "$0")/../crates/edison-stdiod" ensure_tool npx \ "install Node (brew install node) or re-run with --install-deps" \ - brew install node + brew install --quiet node ensure_tool beeper \ "run: brew install beeper/tap/cli (or re-run with --install-deps)" \ - brew install beeper/tap/cli + brew install --quiet beeper/tap/cli ensure_tool edison-stdiod \ "run: cargo install --path crates/edison-stdiod (or re-run with --install-deps)" \ cargo install --path "$stdiod_src" @@ -234,23 +239,20 @@ ensure_beeper_token() { return 0 fi if [ "$DRY_RUN" -eq 1 ]; then - info "would mint via CLI (or require --beeper-token)" + info "would require --beeper-token (Beeper has no headless token mint)" BEEPER_ACCESS_TOKEN="dry-run-placeholder-token" return 0 fi - # The CLI can mint a Desktop API token for approved connections without a - # browser once the server is authorized. If your CLI version exposes a - # different verb, pass the token in via --beeper-token / BEEPER_ACCESS_TOKEN. - local tok="" - tok="$(capture beeper api post /v0/access-tokens --json 2>/dev/null \ - | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" - if [ -n "$tok" ]; then - BEEPER_ACCESS_TOKEN="$tok" - ok "minted via CLI" - return 0 - fi - die "could not obtain a Beeper access token automatically" \ - "create one in Beeper > Settings > Developers > Approved connections, then re-run with --beeper-token " + # Beeper exposes no headless command to mint a Desktop API token: tokens are + # created in the app UI (Approved connections). This is a one-time manual + # step; after it, everything else here is automated and idempotent. + warn "Beeper cannot mint a Desktop API token headlessly; create one once, then re-run" + warn "in Beeper Desktop: Settings > Developers > Beeper Desktop API (enable)," + warn "then Approved connections > + to copy a token" + warn "re-run: $PROG install --ew-api-key --beeper-token --networks ${NETWORKS:-whatsapp}" + warn "(deps and the Beeper Server are already set up, so the re-run is fast)" + die "no Beeper access token provided" \ + "pass --beeper-token (or set BEEPER_ACCESS_TOKEN)" } # --------------------------------------------------------------------------- From 9b0964fa8ccdfe5e1f8aaccd27b6da8f22cb2816 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:15:15 +0000 Subject: [PATCH 07/21] install-beeper: reuse the Beeper CLI session token (headless, no GUI) Beeper has no headless token mint (OAuth is authorization_code + PKCE), but the CLI already holds a valid Desktop API bearer after 'beeper setup'. Add format-agnostic discovery that reuses it so no GUI 'Approved connections' step is needed: - discover_beeper_token gathers candidate strings from 'beeper config path' and the macOS Keychain, then keeps the first that authenticates against the local OAuth userinfo endpoint (discovered from the well-known metadata). - ensure_beeper_token tries discovery before failing; on success it reuses the token silently (only a masked fingerprint is shown, never the secret). - New 'token' subcommand runs discovery standalone for quick verification. - All probes are timeout-bounded so a missing server fails fast instead of hanging. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 92 ++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 9d05520..3f2d816 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -231,7 +231,55 @@ ensure_beeper_server() { # --------------------------------------------------------------------------- # Step 3: Beeper access token (for the stdio MCP proxy child) # --------------------------------------------------------------------------- -# Precedence: explicit token > CLI-issued token > fail with the manual step. +# Show only a safe fingerprint of a secret, never the secret itself. +mask_token() { + local s="$1" + [ "${#s}" -le 12 ] && { printf '' "${#s}"; return; } + printf '%s...%s (len %s)' "${s:0:6}" "${s: -4}" "${#s}" +} + +# discover_beeper_token - reuse the token the `beeper` CLI already holds after +# `beeper setup`, so no GUI "Approved connections" step is needed. Beeper has no +# headless mint (OAuth is authorization_code + PKCE, which needs a browser), but +# the stored session token is a valid Desktop API bearer. We stay format- +# agnostic: gather candidate strings from the CLI config dir and the macOS +# Keychain, then keep the first that authenticates against the local OAuth +# userinfo endpoint. Prints the working token to stdout; diagnostics to stderr. +discover_beeper_token() { + local api="${BEEPER_API_URL:-http://127.0.0.1:23373}" + local uinfo + uinfo="$(curl -s -m 5 "$api/.well-known/oauth-authorization-server" 2>/dev/null \ + | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | grep -oE 'https?://[^"]+' | head -1)" + [ -z "$uinfo" ] && uinfo="$api/oauth/userinfo" + + local cp cands="" + cp="$(beeper config path 2>/dev/null || true)" + if [ -n "$cp" ] && [ -e "$cp" ]; then + cands="$(find "$cp" -type f -exec cat {} + 2>/dev/null \ + | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 60 || true)" + fi + if command -v security >/dev/null 2>&1; then + local svc kc + for svc in beeper Beeper beeper-cli com.beeper.cli "Beeper Desktop" "Beeper Desktop API"; do + kc="$(security find-generic-password -s "$svc" -w 2>/dev/null || true)" + [ -n "$kc" ] && cands="$cands +$kc" + done + fi + + local tok code + while IFS= read -r tok; do + [ -z "$tok" ] && continue + code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 -H "Authorization: Bearer $tok" "$uinfo" 2>/dev/null || true)" + if [ "$code" = "200" ]; then printf '%s' "$tok"; return 0; fi + done < reuse the CLI's session token > manual step. ensure_beeper_token() { step "Beeper access token" if [ -n "$BEEPER_ACCESS_TOKEN" ]; then @@ -239,20 +287,22 @@ ensure_beeper_token() { return 0 fi if [ "$DRY_RUN" -eq 1 ]; then - info "would require --beeper-token (Beeper has no headless token mint)" + info "would reuse the Beeper CLI session token, else require --beeper-token" BEEPER_ACCESS_TOKEN="dry-run-placeholder-token" return 0 fi - # Beeper exposes no headless command to mint a Desktop API token: tokens are - # created in the app UI (Approved connections). This is a one-time manual - # step; after it, everything else here is automated and idempotent. - warn "Beeper cannot mint a Desktop API token headlessly; create one once, then re-run" - warn "in Beeper Desktop: Settings > Developers > Beeper Desktop API (enable)," - warn "then Approved connections > + to copy a token" - warn "re-run: $PROG install --ew-api-key --beeper-token --networks ${NETWORKS:-whatsapp}" - warn "(deps and the Beeper Server are already set up, so the re-run is fast)" - die "no Beeper access token provided" \ - "pass --beeper-token (or set BEEPER_ACCESS_TOKEN)" + local tok + if tok="$(discover_beeper_token)" && [ -n "$tok" ]; then + BEEPER_ACCESS_TOKEN="$tok" + ok "reusing the Beeper CLI's authorized session token, no GUI needed [$(mask_token "$tok")]" + return 0 + fi + # Fallback: no reusable token and none supplied. Guide the one-time manual step. + warn "could not reuse a Beeper CLI token; is the Beeper Server running and logged in?" + warn "otherwise create one once in Beeper Desktop: Settings > Developers > Beeper Desktop API," + warn "then Approved connections > + to copy a token, and re-run with --beeper-token" + die "no Beeper access token available" \ + "pass --beeper-token (or set BEEPER_ACCESS_TOKEN); the re-run is fast, deps are already installed" } # --------------------------------------------------------------------------- @@ -416,6 +466,22 @@ cmd_network() { cmd_mcp_url() { ensure_ew_api_key; print_mcp_url; } +cmd_token() { + step "Discovering a Beeper access token (headless)" + if [ -n "$BEEPER_ACCESS_TOKEN" ]; then + ok "a token is already supplied [$(mask_token "$BEEPER_ACCESS_TOKEN")]" + return 0 + fi + local tok + if tok="$(discover_beeper_token)" && [ -n "$tok" ]; then + ok "found a working token by reusing the Beeper CLI session [$(mask_token "$tok")]" + info "'$PROG install' will use this automatically; no --beeper-token needed" + return 0 + fi + warn "no reusable token found (is the Beeper Server running and logged in?)" + die "no Beeper access token discovered" "pass --beeper-token , or run 'beeper setup --server --install' first" +} + cmd_uninstall() { confirm "remove the stdiod supervisor unit and Beeper child?" || die "aborted" "" command -v edison-stdiod >/dev/null 2>&1 && { @@ -442,6 +508,7 @@ Commands: network add Link a chat network (whatsapp | telegram | linkedin | ...) network list List linked chat networks mcp-url Print the Edison MCP URL and client snippet + token Discover a reusable Beeper token from the CLI session (headless, no GUI) uninstall Remove the tunnel child and supervisor unit Common flags (also settable as UPPER_SNAKE env vars): @@ -511,6 +578,7 @@ main() { status) cmd_status;; network) cmd_network;; mcp-url) cmd_mcp_url;; + token) cmd_token;; uninstall) cmd_uninstall;; ""|help|-h|--help) usage;; *) die "unknown command: $cmd" "run '$PROG --help' for the command list";; From c8578c6b3d7b10f31e9ab0c76e666be2aa5c56df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:19:07 +0000 Subject: [PATCH 08/21] install-beeper: probe IPv6/localhost + scan whole config dir for token Field findings: 'beeper config path' returns a single file (~/.beeper/ config.json), and the Desktop API did not answer on IPv4, suggesting an IPv6-only bind. Make token discovery robust: - Probe the well-known metadata on 127.0.0.1, [::1], and localhost (and BEEPER_API_URL if set), using the first that answers as the validation base. - Scan the whole config directory (dirname of the config file) plus ~/.beeper, not just the single config file. - Report clearly when the Desktop API is unreachable so the cause is obvious. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 3f2d816..401a7ae 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -246,19 +246,40 @@ mask_token() { # Keychain, then keep the first that authenticates against the local OAuth # userinfo endpoint. Prints the working token to stdout; diagnostics to stderr. discover_beeper_token() { - local api="${BEEPER_API_URL:-http://127.0.0.1:23373}" - local uinfo - uinfo="$(curl -s -m 5 "$api/.well-known/oauth-authorization-server" 2>/dev/null \ - | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' \ - | grep -oE 'https?://[^"]+' | head -1)" - [ -z "$uinfo" ] && uinfo="$api/oauth/userinfo" - - local cp cands="" + # Find a reachable Desktop API base URL. Beeper may bind IPv6-only, so probe + # [::1] and localhost as well as 127.0.0.1 (honor BEEPER_API_URL if set). + local base="" uinfo="" meta h + local hosts="127.0.0.1 [::1] localhost" + [ -n "${BEEPER_API_URL:-}" ] && hosts="$BEEPER_API_URL $hosts" + for h in $hosts; do + case "$h" in http*) meta_url="$h/.well-known/oauth-authorization-server"; base="$h";; + *) meta_url="http://$h:23373/.well-known/oauth-authorization-server"; base="http://$h:23373";; esac + meta="$(curl -s -m 4 "$meta_url" 2>/dev/null || true)" + if [ -n "$meta" ]; then + uinfo="$(printf '%s' "$meta" | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' | grep -oE 'https?://[^"]+' | head -1)" + break + fi + base="" + done + if [ -z "$base" ]; then + warn "the Beeper Desktop API did not answer on 127.0.0.1/[::1]/localhost:23373" + warn "it must be running and enabled for the tunnel child to reach it" + return 1 + fi + [ -z "$uinfo" ] && uinfo="$base/oauth/userinfo" + + # Candidate token strings from the CLI config dir (scan the whole directory, + # since 'beeper config path' may point at a single file) plus the Keychain. + local cp dirs="$HOME/.beeper" cands="" d cp="$(beeper config path 2>/dev/null || true)" - if [ -n "$cp" ] && [ -e "$cp" ]; then - cands="$(find "$cp" -type f -exec cat {} + 2>/dev/null \ - | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 60 || true)" + if [ -n "$cp" ]; then + if [ -d "$cp" ]; then dirs="$cp $dirs"; else dirs="$(dirname "$cp") $dirs"; fi fi + for d in $dirs; do + [ -d "$d" ] || continue + cands="$cands +$(find "$d" -type f -exec cat {} + 2>/dev/null | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 80)" + done if command -v security >/dev/null 2>&1; then local svc kc for svc in beeper Beeper beeper-cli com.beeper.cli "Beeper Desktop" "Beeper Desktop API"; do @@ -274,7 +295,7 @@ $kc" code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 -H "Authorization: Bearer $tok" "$uinfo" 2>/dev/null || true)" if [ "$code" = "200" ]; then printf '%s' "$tok"; return 0; fi done < Date: Wed, 15 Jul 2026 17:22:39 +0000 Subject: [PATCH 09/21] install-beeper: detect Beeper Server by real API probe, not 'beeper status' Root cause of the empty ~/.beeper and dead 23373: 'beeper status' exits 0 even when no server is configured, so ensure_beeper_server printed 'already running' and SKIPPED 'beeper setup --server --install'. The server was never authorized and the Desktop API never started. - Add beeper_api_base(): probe the well-known metadata across ports 23373-23378 on 127.0.0.1/localhost/[::1] (honor BEEPER_API_URL), echo the first reachable base URL. - ensure_beeper_server now runs setup when the API is unreachable and re-probes after, failing with a clear 'finish the browser authorization' message if it still is not up. - discover_beeper_token reuses beeper_api_base for the same port range. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 65 ++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 401a7ae..2190243 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -217,15 +217,47 @@ ensure_deps() { # --------------------------------------------------------------------------- # Step 2: headless Beeper Server # --------------------------------------------------------------------------- +# Echo the first reachable Beeper Desktop API base URL, or nothing (exit 1). +# The CLI scans ports 23373-23378 on 127.0.0.1/localhost; the server may also +# bind IPv6 ([::1]). BEEPER_API_URL overrides the probe. +beeper_api_base() { + local wk="/.well-known/oauth-authorization-server" code h p url + if [ -n "${BEEPER_API_URL:-}" ]; then + code="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "${BEEPER_API_URL}${wk}" 2>/dev/null || true)" + [ -n "$code" ] && [ "$code" != "000" ] && { printf '%s' "$BEEPER_API_URL"; return 0; } + fi + for h in 127.0.0.1 localhost "[::1]"; do + for p in 23373 23374 23375 23376 23377 23378; do + url="http://$h:$p" + code="$(curl -s -m 2 -o /dev/null -w '%{http_code}' "${url}${wk}" 2>/dev/null || true)" + [ -n "$code" ] && [ "$code" != "000" ] && { printf '%s' "$url"; return 0; } + done + done + return 1 +} + ensure_beeper_server() { step "Beeper Server (headless)" - # `beeper status` exits 0 when a server target is adopted and reachable. - if [ "$DRY_RUN" -eq 0 ] && beeper status >/dev/null 2>&1; then - ok "already running" + local base + if [ "$DRY_RUN" -eq 1 ]; then + info "would ensure the headless Beeper Server is running (browser auth on first setup)" + run beeper setup --server --install return 0 fi - info "installing headless server (a browser opens once to authorize your Beeper account)" + # Probe the real Desktop API, not `beeper status` (which exits 0 even with no + # server configured, so it used to skip setup and leave nothing listening). + if base="$(beeper_api_base)"; then + ok "Desktop API reachable at $base" + return 0 + fi + info "installing/starting the headless server (a browser opens once to authorize your Beeper account)" run beeper setup --server --install + if base="$(beeper_api_base)"; then + ok "Desktop API reachable at $base" + else + die "the Beeper Desktop API is not reachable on 23373-23378 after setup" \ + "finish the browser authorization opened by 'beeper setup --server --install', then re-run: $PROG install" + fi } # --------------------------------------------------------------------------- @@ -246,26 +278,15 @@ mask_token() { # Keychain, then keep the first that authenticates against the local OAuth # userinfo endpoint. Prints the working token to stdout; diagnostics to stderr. discover_beeper_token() { - # Find a reachable Desktop API base URL. Beeper may bind IPv6-only, so probe - # [::1] and localhost as well as 127.0.0.1 (honor BEEPER_API_URL if set). - local base="" uinfo="" meta h - local hosts="127.0.0.1 [::1] localhost" - [ -n "${BEEPER_API_URL:-}" ] && hosts="$BEEPER_API_URL $hosts" - for h in $hosts; do - case "$h" in http*) meta_url="$h/.well-known/oauth-authorization-server"; base="$h";; - *) meta_url="http://$h:23373/.well-known/oauth-authorization-server"; base="http://$h:23373";; esac - meta="$(curl -s -m 4 "$meta_url" 2>/dev/null || true)" - if [ -n "$meta" ]; then - uinfo="$(printf '%s' "$meta" | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' | grep -oE 'https?://[^"]+' | head -1)" - break - fi - base="" - done - if [ -z "$base" ]; then - warn "the Beeper Desktop API did not answer on 127.0.0.1/[::1]/localhost:23373" - warn "it must be running and enabled for the tunnel child to reach it" + local base uinfo="" + if ! base="$(beeper_api_base)"; then + warn "the Beeper Desktop API is not reachable on 23373-23378" + warn "run 'beeper setup --server --install' and finish the browser authorization first" return 1 fi + uinfo="$(curl -s -m 4 "$base/.well-known/oauth-authorization-server" 2>/dev/null \ + | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | grep -oE 'https?://[^"]+' | head -1)" [ -z "$uinfo" ] && uinfo="$base/oauth/userinfo" # Candidate token strings from the CLI config dir (scan the whole directory, From fe86a99c7e7cca173d16ff44930555d0a3f6dd6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 17:56:09 +0000 Subject: [PATCH 10/21] install-beeper: read Beeper accessToken from target JSON directly Field finding: the CLI stores the bearer verbatim as "accessToken" in ~/.beeper/targets/.json. The previous discovery cat'd the entire ~/.beeper tree (including the 100MB server binary and sqlite DBs), so the real token was crowded out past the candidate cutoff. - Extract "accessToken" straight from targets/*.json and try those first, remembering the first as the canonical token. - Limit the broad fallback scan to JSON files under 1M (skip binaries/DBs/logs). - If no candidate passes the live userinfo check, fall back to the explicit accessToken rather than failing (userinfo can be strict about scopes; a truly wrong token surfaces as a real child auth error later). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 40 ++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 2190243..c063e2e 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -289,18 +289,39 @@ discover_beeper_token() { | grep -oE 'https?://[^"]+' | head -1)" [ -z "$uinfo" ] && uinfo="$base/oauth/userinfo" - # Candidate token strings from the CLI config dir (scan the whole directory, - # since 'beeper config path' may point at a single file) plus the Keychain. - local cp dirs="$HOME/.beeper" cands="" d + # Resolve the CLI config dir (`beeper config path` returns ~/.beeper/config.json). + local cp dirs="$HOME/.beeper" d cp="$(beeper config path 2>/dev/null || true)" if [ -n "$cp" ]; then if [ -d "$cp" ]; then dirs="$cp $dirs"; else dirs="$(dirname "$cp") $dirs"; fi fi + + # Primary: the target files store the bearer verbatim as "accessToken" + # (~/.beeper/targets/.json). Extract those first and remember the first + # one as the canonical token to fall back on if live validation is flaky. + local explicit="" f t cands="" + for d in $dirs; do + [ -d "$d" ] || continue + while IFS= read -r f; do + [ -n "$f" ] || continue + t="$(sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -n1)" + if [ -n "$t" ]; then cands="$t +$cands"; [ -z "$explicit" ] && explicit="$t"; fi + done </dev/null) +EOF2 + done + + # Secondary: token-shaped strings from small JSON files only (skip the DBs, + # logs, and the bundled server binary so real tokens are not crowded out). for d in $dirs; do [ -d "$d" ] || continue cands="$cands -$(find "$d" -type f -exec cat {} + 2>/dev/null | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 80)" +$(find "$d" -type f -name '*.json' -size -1M 2>/dev/null -exec cat {} + 2>/dev/null \ + | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 120)" done + + # Best-effort Keychain fallback. if command -v security >/dev/null 2>&1; then local svc kc for svc in beeper Beeper beeper-cli com.beeper.cli "Beeper Desktop" "Beeper Desktop API"; do @@ -310,14 +331,23 @@ $kc" done fi + # Validate candidates against userinfo; use the first that authenticates. local tok code while IFS= read -r tok; do [ -z "$tok" ] && continue code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 -H "Authorization: Bearer $tok" "$uinfo" 2>/dev/null || true)" if [ "$code" = "200" ]; then printf '%s' "$tok"; return 0; fi done < Date: Wed, 15 Jul 2026 17:59:17 +0000 Subject: [PATCH 11/21] install-beeper: fix 'server add' hyphen arg + pass Beeper base URL to child Real macOS run got all the way through headless token discovery and daemon install, then failed at 'edison-stdiod server add ... --arg -y': clap reads a hyphen-leading value in the space form as an unknown flag. Use the --arg=-y equals form (verified: parses where the space form errors). Also pass the discovered Desktop API base URL to the child as BEEPER_API_URL and BEEPER_DESKTOP_BASE_URL, because the server may bind a non-default port (23374 here) while @beeper/desktop-mcp defaults to 23373. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index c063e2e..3ce06a0 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -409,10 +409,12 @@ wire_tunnel() { if [ "$DRY_RUN" -eq 0 ] && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then ok "tunnel child '$SERVER_NAME' already registered" else + # Use the --arg=VALUE form: clap rejects a hyphen-leading value in the + # space form ('--arg -y' is read as an unknown flag), so '--arg=-y'. if ! run edison-stdiod server add "$SERVER_NAME" \ --display-name "Beeper" \ --command npx \ - --arg -y --arg "$MCP_PKG"; then + --arg=-y --arg="$MCP_PKG"; then die "edison-stdiod server add failed for '$SERVER_NAME'" \ "confirm the daemon is logged in and the backend is reachable, then re-run: $PROG install" fi @@ -435,14 +437,18 @@ bind_beeper_token() { local path="${EW_SERVER_ENV_PATH:-/api/v1/servers/${SERVER_NAME}/env}" local url="${EW_BACKEND}${path}" if [ "$DRY_RUN" -eq 1 ]; then - run curl -X POST "$url" "(set BEEPER_ACCESS_TOKEN, respawns child)" + run curl -X POST "$url" "(set BEEPER_ACCESS_TOKEN + base URL, respawns child)" return 0 fi + # Also pass the discovered base URL: the server may bind a non-default port + # (e.g. 23374) while the proxy defaults to 23373. Send both common env names. + local api_base; api_base="$(beeper_api_base 2>/dev/null || true)" + [ -z "$api_base" ] && api_base="http://127.0.0.1:23373" local code code="$(curl -s -o /dev/null -w '%{http_code}' -m 30 --connect-timeout 5 -X POST "$url" \ -H "Authorization: Bearer ${EW_API_KEY}" \ -H "Content-Type: application/json" \ - --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\"}}" 2>/dev/null || true)" + --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\",\"BEEPER_API_URL\":\"${api_base}\",\"BEEPER_DESKTOP_BASE_URL\":\"${api_base}\"}}" 2>/dev/null || true)" [ -z "$code" ] && code="000" case "$code" in 2*) ok "bound to child '$SERVER_NAME' and respawned"; return 0;; From 05ca7bfe4b3f1c1f8b5129545900faa0c4772993 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:03:19 +0000 Subject: [PATCH 12/21] install-beeper: validate Edison API key up front with a clear error A real run reached the daemon and then failed inside 'server add' with a raw 'HTTP 401 Invalid or inactive API key'. Pre-validate the key against GET /api/v1/servers in ensure_ew_api_key so a bad or wrong-environment key fails early with an actionable message (including the demo backend hint), before login/install/server-add run. Non-401 responses proceed; unreachable backend warns and continues. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 3ce06a0..ce27e6e 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -381,12 +381,25 @@ ensure_beeper_token() { # Step 4a: Edison Watch account + API key # --------------------------------------------------------------------------- ensure_ew_api_key() { - if [ -n "$EW_API_KEY" ]; then + if [ -z "$EW_API_KEY" ]; then + die "no Edison Watch API key provided" \ + "sign in at ${EW_BACKEND}, create an API key, then re-run with --ew-api-key edison_..." + fi + if [ "$DRY_RUN" -eq 1 ]; then ok "edison account: using supplied API key" return 0 fi - die "no Edison Watch API key provided" \ - "sign in at ${EW_BACKEND}, create an API key, then re-run with --ew-api-key ew_live_..." + # Validate the key up front so a bad key fails here with a clear message, + # not mid-flow inside 'edison-stdiod server add'. + local code + code="$(curl -s -o /dev/null -w '%{http_code}' -m 15 --connect-timeout 5 \ + -H "Authorization: Bearer ${EW_API_KEY}" "${EW_BACKEND%/}/api/v1/servers" 2>/dev/null || true)" + case "$code" in + 401|403) die "Edison rejected the API key at ${EW_BACKEND} (http ${code}: invalid or inactive)" \ + "check the key is active and from this environment; for demo pass --ew-backend https://demo-dashboard.edison.watch";; + 000) warn "could not reach ${EW_BACKEND} to validate the key; continuing";; + *) ok "edison account: API key accepted by ${EW_BACKEND}";; + esac } # --------------------------------------------------------------------------- From 5b8d42e2b4b5e6af2d593ff21f8a8fc620b67a6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:08:26 +0000 Subject: [PATCH 13/21] install-beeper: self-heal 'server add' 409 by remove + re-add A stale 'beeper' registered under an old device_id caused a 409 (names are unique per org, so the this-device 'server list' check missed it). On a conflict, remove the org-level server (admin-only) and re-add it on this device. If it still fails (e.g. non-admin key), fail with guidance to use --server-name or delete it in the dashboard. Also skip the live probe cleanly under --dry-run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 43 +++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index ce27e6e..5ee6fd3 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -417,22 +417,35 @@ wire_tunnel() { "macOS needs no privileges; Linux needs a logged-in systemd --user session. Fix that, then re-run: $PROG install" fi - # Idempotent: only add the child if it is not already registered. The live - # probe is skipped under --dry-run (nothing is registered to probe). - if [ "$DRY_RUN" -eq 0 ] && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then - ok "tunnel child '$SERVER_NAME' already registered" - else - # Use the --arg=VALUE form: clap rejects a hyphen-leading value in the - # space form ('--arg -y' is read as an unknown flag), so '--arg=-y'. - if ! run edison-stdiod server add "$SERVER_NAME" \ - --display-name "Beeper" \ - --command npx \ - --arg=-y --arg="$MCP_PKG"; then - die "edison-stdiod server add failed for '$SERVER_NAME'" \ - "confirm the daemon is logged in and the backend is reachable, then re-run: $PROG install" - fi - ok "tunnel child '$SERVER_NAME' registered" + # Use the --arg=VALUE form: clap rejects a hyphen-leading value in the space + # form ('--arg -y' is read as an unknown flag), so '--arg=-y'. + if [ "$DRY_RUN" -eq 1 ]; then + run edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ + --command npx --arg=-y --arg="$MCP_PKG" + return 0 + fi + # Idempotent on this device. + if edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + ok "tunnel child '$SERVER_NAME' already registered on this device" + return 0 + fi + local out rc + out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ + --command npx --arg=-y --arg="$MCP_PKG" 2>&1)"; rc=$? + # A name is unique per org: a 409 means it exists under another device (a + # stale registration). Remove it (org-level, admin-only) and re-add here. + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE 'already exists|CONFLICT|409'; then + warn "'$SERVER_NAME' already exists in this org (stale/other device); re-registering it here" + edison-stdiod server remove "$SERVER_NAME" >/dev/null 2>&1 || true + out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ + --command npx --arg=-y --arg="$MCP_PKG" 2>&1)"; rc=$? + fi + if [ "$rc" -ne 0 ]; then + printf '%s\n' "$out" >&2 + die "edison-stdiod server add failed for '$SERVER_NAME'" \ + "if it still conflicts, your key may lack admin (remove needs it); pass --server-name or delete '$SERVER_NAME' in the dashboard, then re-run: $PROG install" fi + ok "tunnel child '$SERVER_NAME' registered" } # --------------------------------------------------------------------------- From 61d7a59502c3c2e9c286353515a15c1624a186ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:50:45 +0000 Subject: [PATCH 14/21] install-beeper: stop set -e from aborting on non-zero server add The 409 self-heal never ran: 'out=$(edison-stdiod server add ...)' as a bare assignment fails under set -e when the command returns non-zero, so bash exited silently right after the daemon install (output was captured, nothing printed). Guard both add captures with '&& rc=0 || rc=$?' so the failure is recorded and the conflict handler runs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 5ee6fd3..314e674 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -429,16 +429,18 @@ wire_tunnel() { ok "tunnel child '$SERVER_NAME' already registered on this device" return 0 fi + # Capture with '&& rc=0 || rc=$?' so a non-zero add does not trip set -e + # (a bare 'out=$(cmd)' assignment failing would abort the script silently). local out rc out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ - --command npx --arg=-y --arg="$MCP_PKG" 2>&1)"; rc=$? + --command npx --arg=-y --arg="$MCP_PKG" 2>&1)" && rc=0 || rc=$? # A name is unique per org: a 409 means it exists under another device (a # stale registration). Remove it (org-level, admin-only) and re-add here. if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE 'already exists|CONFLICT|409'; then warn "'$SERVER_NAME' already exists in this org (stale/other device); re-registering it here" edison-stdiod server remove "$SERVER_NAME" >/dev/null 2>&1 || true out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ - --command npx --arg=-y --arg="$MCP_PKG" 2>&1)"; rc=$? + --command npx --arg=-y --arg="$MCP_PKG" 2>&1)" && rc=0 || rc=$? fi if [ "$rc" -ne 0 ]; then printf '%s\n' "$out" >&2 From adcc884b1cd9d386f4da3d3ece85e8dcc999e3fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:01:05 +0000 Subject: [PATCH 15/21] install-beeper: report STEP_UP_REQUIRED accurately on server add Adding a stdio_tunnel server is gated behind interactive step-up re-auth (backend _require_recent_supabase_login). Detect the STEP_UP_REQUIRED response and explain the real cause and unblock (add once in the dashboard, or set STEP_UP_BYPASS_EMAIL_DOMAINS on a non-release backend) instead of the generic admin-key message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 314e674..7df93d4 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -444,6 +444,10 @@ wire_tunnel() { fi if [ "$rc" -ne 0 ]; then printf '%s\n' "$out" >&2 + if printf '%s' "$out" | grep -qi 'STEP_UP_REQUIRED\|fresh re-login\|re-authenticate'; then + die "adding a local server is gated behind interactive step-up re-auth (by design)" \ + "add '$SERVER_NAME' once in the ${EW_BACKEND} dashboard (targets this device, clears the 5-min re-auth modal), then re-run: $PROG install. On non-release backends an admin can also set STEP_UP_BYPASS_EMAIL_DOMAINS." + fi die "edison-stdiod server add failed for '$SERVER_NAME'" \ "if it still conflicts, your key may lack admin (remove needs it); pass --server-name or delete '$SERVER_NAME' in the dashboard, then re-run: $PROG install" fi From 4531dd318d3b1312345f0b91d845b223d32ea56b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 22:18:30 +0000 Subject: [PATCH 16/21] docs: design proposal for headless server add (device-pairing token) Documents the STEP_UP_REQUIRED blocker on POST /api/v1/servers for stdio_tunnel adds and proposes a device-pairing token flow that keeps the security gate while making one-command install headless after a one-time device enrollment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- docs/headless-server-add.md | 138 ++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/headless-server-add.md diff --git a/docs/headless-server-add.md b/docs/headless-server-add.md new file mode 100644 index 0000000..ac4787b --- /dev/null +++ b/docs/headless-server-add.md @@ -0,0 +1,138 @@ +# Headless `server add` - the step-up blocker and how to fix it + +## Problem + +The install flow (`scripts/install-beeper.sh`, and any future one-command +onboarding) is fully headless **except** for one call: registering a +`stdio_tunnel` server via `POST /api/v1/servers`. + +That endpoint gates `stdio_tunnel` creation behind a **step-up re-auth**: +the request must carry an `X-Edison-Step-Up-Token` - a Supabase JWT minted +by an *interactive* login no more than 5 minutes ago +(`_STEP_UP_MAX_AGE_SECONDS = 300`, in +`src/api/v1/routes/servers_crud_create.py`). A backend API key alone does +not satisfy it, so the CLI gets: + +``` +401 STEP_UP_REQUIRED: "Adding a local server requires a fresh re-login. +Re-authenticate in the dashboard and try again." +``` + +The CLI HTTP client only knows how to send `Authorization: Bearer ` +(`crates/edison-stdiod/src/http.rs`), and there is no headless way to obtain +a step-up JWT (it requires an interactive browser login). So the gate and +the "one command, no GUI" goal are in direct conflict. + +``` + install script ──POST /api/v1/servers──▶ backend + │ Bearer │ + │ (no step-up token - can't mint ▼ + │ one without a browser) _require_recent_supabase_login + │ needs X-Edison-Step-Up-Token < 5 min + └───────────────────────────▶ 401 STEP_UP_REQUIRED +``` + +Today's only escapes: +- `TEST_AUTH_MODE=local` (local dev only), or +- `STEP_UP_BYPASS_EMAIL_DOMAINS=` - honored on dev/demo, **ignored + on release**. + +Neither is a real production answer. + +## Why the gate exists + +`stdio_tunnel` servers run an **arbitrary command on the user's machine** +(`command` + `args`). An attacker who steals a long-lived API key could +otherwise register `command=/bin/sh` and get remote code execution through +the daemon. Requiring a fresh interactive login raises the bar from +"leaked key" to "leaked key + live human session". That protection is +worth keeping; the fix must preserve it, not delete it. + +## The leverage point: the daemon is already a trusted device + +The daemon authenticates to the backend over the WS tunnel and shows up in +the admin **Devices** page. The dangerous part of `server add` - "run this +command" - targets a *specific already-enrolled device* (`device_id` from +`config.toml`). So the trust we need isn't "is this a live browser +session"; it's "did a human, once, authorize *this device* to run local +servers". + +That reframes the fix as **device enrollment**, not per-call re-auth. + +## Proposal: device-pairing token + +Mint a short-lived, single-purpose **pairing token** in the dashboard (one +interactive step-up, exactly where the human already is), hand it to the +daemon once at enrollment, and let the backend accept it in place of a +step-up JWT **only** for `stdio_tunnel` adds scoped to that device. + +``` + ┌─ dashboard (human, one step-up) ─┐ + │ "Pair a device" → mint token │ + │ pt_, ttl≈15m, org+admin │ + └──────────────┬───────────────────┘ + │ copy/paste OR deep link + ▼ + edison-stdiod login --pairing-token pt_ + │ POST /api/v1/devices/pair + │ { pairing_token, device_id, device_label } + ▼ + backend verifies pt_, binds device_id → org, returns a + device credential stored in config.toml (0600) + │ + ▼ + later: server add ──Bearer api_key + X-Edison-Device-Cred──▶ backend + accepts device-cred in lieu of step-up *iff* + body.device_id == the paired device → 200 OK +``` + +### Backend changes (`edison-watch`) +1. **Mint** - `POST /api/v1/devices/pairing-token`, admin + step-up + required (reuses the existing interactive gate). Returns + `pt_`, TTL ~15 min, single use, scoped to `org_id`. +2. **Redeem** - `POST /api/v1/devices/pair` accepts `{pairing_token, + device_id, device_label}`, verifies + burns the token, records the + device as *authorized-for-local-servers*, and returns a device + credential (or just marks the existing device row). +3. **Relax the gate, narrowly** - in `_require_recent_supabase_login`, + accept a valid paired-device credential **only** when the request is a + `stdio_tunnel` add whose `device_id` matches the paired device. Every + other step-up call is unchanged. + +### CLI changes (`stdiod`) +1. `login --pairing-token ` → calls `/devices/pair`, stores the + returned device credential next to `api_key` in `config.toml`. +2. `http.rs` attaches the device credential header on `server add` when + present. +3. `install-beeper.sh` passes `--pairing-token` through (env or flag); if + absent, it prints the dashboard "Pair a device" URL and stops with an + actionable message instead of failing on a raw 401. + +### Resulting UX +- **First device, ever:** one interactive step (mint the token in the + dashboard) → paste into the installer → everything else headless. +- **Re-runs / re-installs on the same device:** fully headless (device + already paired). +- **CI / fleet rollout:** mint one pairing token per device out-of-band, + inject via env - no browser in the automation path. + +This trades "interactive login on *every* add" for "interactive +enrollment *once per device*", which is both safer to reason about +(device is the unit of trust) and compatible with headless install. + +## Alternatives considered + +- **Loosen the gate to accept API keys** - rejected: reintroduces the + leaked-key-→-RCE risk the gate exists to stop. +- **CLI mints its own step-up JWT** - not possible; step-up requires an + interactive Supabase login by construction. +- **`STEP_UP_BYPASS_EMAIL_DOMAINS` in release** - rejected: domain-wide, + static, and explicitly disabled in release for good reason. + +## Interim (demo/testing only) + +To exercise the full pipeline **today** without shipping the above, set +`STEP_UP_BYPASS_EMAIL_DOMAINS=` on the **demo** backend (it is +honored on non-release) and run the installer against +`--ew-backend https://demo-dashboard.edison.watch`. This is a test-only +unblock, not the production design. From 4b3f1e88274ae56f15001474cf259fd06bb28d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 10:24:32 +0000 Subject: [PATCH 17/21] install-beeper: rewrite onto shipped device-auth flow; drop obsolete design doc The step-up blocker this script fought is gone: stdiod #16 (browser device authorization) + backend scoped device authorization shipped on main. Rewrite the installer to match, and remove docs/headless-server-add.md (it proposed exactly what shipped). - Auth: `edison-stdiod login --backend ` device/browser flow instead of --api-key. Idempotent via the persisted client credential; --no-open for headless, --relogin to force. - Server add: treat as submit-for-approval (client mcp-requests). Drop the step-up handling, the 409 remove+re-add self-heal, and the api-key pre-validation, none of which apply under device auth. - Token: stop POSTing a fabricated /servers/{name}/env route. Under device auth the child's env arrives from the backend, so discover a reusable Beeper token and print it for the operator to set on the server in the dashboard. - Scope to the real capability: Beeper serves MCP only from the Desktop app (headless server has none, per github.com/beeper/cli issue #20). Probe the Desktop API and print the exact human steps (enable MCP, approve server, set token) rather than pretending they are automatable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- docs/headless-server-add.md | 138 ---------- scripts/install-beeper.sh | 510 +++++++++++++++--------------------- 2 files changed, 211 insertions(+), 437 deletions(-) delete mode 100644 docs/headless-server-add.md diff --git a/docs/headless-server-add.md b/docs/headless-server-add.md deleted file mode 100644 index ac4787b..0000000 --- a/docs/headless-server-add.md +++ /dev/null @@ -1,138 +0,0 @@ -# Headless `server add` - the step-up blocker and how to fix it - -## Problem - -The install flow (`scripts/install-beeper.sh`, and any future one-command -onboarding) is fully headless **except** for one call: registering a -`stdio_tunnel` server via `POST /api/v1/servers`. - -That endpoint gates `stdio_tunnel` creation behind a **step-up re-auth**: -the request must carry an `X-Edison-Step-Up-Token` - a Supabase JWT minted -by an *interactive* login no more than 5 minutes ago -(`_STEP_UP_MAX_AGE_SECONDS = 300`, in -`src/api/v1/routes/servers_crud_create.py`). A backend API key alone does -not satisfy it, so the CLI gets: - -``` -401 STEP_UP_REQUIRED: "Adding a local server requires a fresh re-login. -Re-authenticate in the dashboard and try again." -``` - -The CLI HTTP client only knows how to send `Authorization: Bearer ` -(`crates/edison-stdiod/src/http.rs`), and there is no headless way to obtain -a step-up JWT (it requires an interactive browser login). So the gate and -the "one command, no GUI" goal are in direct conflict. - -``` - install script ──POST /api/v1/servers──▶ backend - │ Bearer │ - │ (no step-up token - can't mint ▼ - │ one without a browser) _require_recent_supabase_login - │ needs X-Edison-Step-Up-Token < 5 min - └───────────────────────────▶ 401 STEP_UP_REQUIRED -``` - -Today's only escapes: -- `TEST_AUTH_MODE=local` (local dev only), or -- `STEP_UP_BYPASS_EMAIL_DOMAINS=` - honored on dev/demo, **ignored - on release**. - -Neither is a real production answer. - -## Why the gate exists - -`stdio_tunnel` servers run an **arbitrary command on the user's machine** -(`command` + `args`). An attacker who steals a long-lived API key could -otherwise register `command=/bin/sh` and get remote code execution through -the daemon. Requiring a fresh interactive login raises the bar from -"leaked key" to "leaked key + live human session". That protection is -worth keeping; the fix must preserve it, not delete it. - -## The leverage point: the daemon is already a trusted device - -The daemon authenticates to the backend over the WS tunnel and shows up in -the admin **Devices** page. The dangerous part of `server add` - "run this -command" - targets a *specific already-enrolled device* (`device_id` from -`config.toml`). So the trust we need isn't "is this a live browser -session"; it's "did a human, once, authorize *this device* to run local -servers". - -That reframes the fix as **device enrollment**, not per-call re-auth. - -## Proposal: device-pairing token - -Mint a short-lived, single-purpose **pairing token** in the dashboard (one -interactive step-up, exactly where the human already is), hand it to the -daemon once at enrollment, and let the backend accept it in place of a -step-up JWT **only** for `stdio_tunnel` adds scoped to that device. - -``` - ┌─ dashboard (human, one step-up) ─┐ - │ "Pair a device" → mint token │ - │ pt_, ttl≈15m, org+admin │ - └──────────────┬───────────────────┘ - │ copy/paste OR deep link - ▼ - edison-stdiod login --pairing-token pt_ - │ POST /api/v1/devices/pair - │ { pairing_token, device_id, device_label } - ▼ - backend verifies pt_, binds device_id → org, returns a - device credential stored in config.toml (0600) - │ - ▼ - later: server add ──Bearer api_key + X-Edison-Device-Cred──▶ backend - accepts device-cred in lieu of step-up *iff* - body.device_id == the paired device → 200 OK -``` - -### Backend changes (`edison-watch`) -1. **Mint** - `POST /api/v1/devices/pairing-token`, admin + step-up - required (reuses the existing interactive gate). Returns - `pt_`, TTL ~15 min, single use, scoped to `org_id`. -2. **Redeem** - `POST /api/v1/devices/pair` accepts `{pairing_token, - device_id, device_label}`, verifies + burns the token, records the - device as *authorized-for-local-servers*, and returns a device - credential (or just marks the existing device row). -3. **Relax the gate, narrowly** - in `_require_recent_supabase_login`, - accept a valid paired-device credential **only** when the request is a - `stdio_tunnel` add whose `device_id` matches the paired device. Every - other step-up call is unchanged. - -### CLI changes (`stdiod`) -1. `login --pairing-token ` → calls `/devices/pair`, stores the - returned device credential next to `api_key` in `config.toml`. -2. `http.rs` attaches the device credential header on `server add` when - present. -3. `install-beeper.sh` passes `--pairing-token` through (env or flag); if - absent, it prints the dashboard "Pair a device" URL and stops with an - actionable message instead of failing on a raw 401. - -### Resulting UX -- **First device, ever:** one interactive step (mint the token in the - dashboard) → paste into the installer → everything else headless. -- **Re-runs / re-installs on the same device:** fully headless (device - already paired). -- **CI / fleet rollout:** mint one pairing token per device out-of-band, - inject via env - no browser in the automation path. - -This trades "interactive login on *every* add" for "interactive -enrollment *once per device*", which is both safer to reason about -(device is the unit of trust) and compatible with headless install. - -## Alternatives considered - -- **Loosen the gate to accept API keys** - rejected: reintroduces the - leaked-key-→-RCE risk the gate exists to stop. -- **CLI mints its own step-up JWT** - not possible; step-up requires an - interactive Supabase login by construction. -- **`STEP_UP_BYPASS_EMAIL_DOMAINS` in release** - rejected: domain-wide, - static, and explicitly disabled in release for good reason. - -## Interim (demo/testing only) - -To exercise the full pipeline **today** without shipping the above, set -`STEP_UP_BYPASS_EMAIL_DOMAINS=` on the **demo** backend (it is -honored on non-release) and run the installer against -`--ew-backend https://demo-dashboard.edison.watch`. This is a test-only -unblock, not the production design. diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 7df93d4..50867c1 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -1,32 +1,37 @@ #!/usr/bin/env bash # -# install-beeper.sh - one-command installer that wires Beeper into the Edison -# Watch MCP gateway on macOS. +# install-beeper.sh - wire Beeper into the Edison Watch MCP gateway on macOS. # -# It does five things, each idempotent: -# 1. Install prerequisites (node/npx, the `beeper` CLI, `edison-stdiod`). -# 2. Bring up a headless Beeper Server on 127.0.0.1:23373 (one OAuth click). -# 3. Log the stdiod daemon in to an Edison Watch account and supervise it. -# 4. Register Beeper's stdio MCP proxy (`npx @beeper/desktop-mcp`) as a -# tunnel child so the gateway can reach it. -# 5. Bind the Beeper access token so the child can authenticate, then print -# the Edison MCP URL the user hands to their AI client. +# Reality check (2026-07): Beeper only exposes an MCP endpoint from the Beeper +# *Desktop app* (enabled once under Settings > Developers > MCP). The headless +# `beeper` Server ships no MCP endpoint (github.com/beeper/cli issue #20), so a +# fully unattended install is not possible today. This script automates the +# Edison side end to end and gives exact, checkable instructions for the few +# steps that Beeper and the dashboard still gate behind a human. # -# Design note: this is a thin orchestrator over two CLIs (`beeper` and -# `edison-stdiod`) plus a couple of REST calls. It is built to be driven by an -# agent OR a human: every input is a flag or an env var, missing required -# inputs fail fast with the exact command to fix them, and nothing blocks on an -# interactive prompt unless you opt in with `--interactive`. +# What it automates: +# 1. Install prerequisites (node/npx, `edison-stdiod`). +# 2. Authorize this device to Edison Watch via the stdiod browser/device flow +# (`edison-stdiod login`; no API key paste, no step-up dance). +# 3. Supervise the tunnel daemon (`edison-stdiod install`). +# 4. Submit Beeper's stdio MCP proxy (`npx @beeper/desktop-mcp`) as a tunnel +# server (`edison-stdiod server add`, which requests admin approval). # -# Verified command surfaces (2026-07): -# beeper setup --server --install headless Beeper Server -# beeper accounts add link WhatsApp / Telegram / LinkedIn -# beeper auth email start|response non-browser email-code sign-in -# edison-stdiod login|install|status supervise the tunnel daemon -# edison-stdiod server add|list|remove register a stdio MCP child +# What still needs a human (each one printed with the exact action): +# A. Enable MCP in Beeper Desktop (Settings > Developers > MCP) so :23373 +# answers, and link WhatsApp / Telegram / etc. in the Beeper app. +# B. Approve the submitted `beeper` server once in the Edison dashboard. +# C. Set BEEPER_ACCESS_TOKEN on that server in the dashboard so the child can +# authenticate. The script discovers a reusable token for you to paste. # -# stdiod today supports the supervised daemon on macOS only. This script fails -# fast on other platforms and tells you so. +# End-to-end topology once those are done: +# AI client --MCP--> Edison Gateway --WS tunnel--> edison-stdiod +# --stdio--> npx @beeper/desktop-mcp --HTTP :23373--> Beeper Desktop +# --> WhatsApp / Telegram / LinkedIn / ... +# +# Built to be driven by an agent or a human: every input is a flag or an +# UPPER_SNAKE env var, nothing blocks on a prompt unless you pass --interactive, +# and missing inputs fail fast with the exact command to fix them. set -euo pipefail @@ -35,8 +40,8 @@ set -euo pipefail export RUST_BACKTRACE="${RUST_BACKTRACE:-0}" export RUST_LIB_BACKTRACE="${RUST_LIB_BACKTRACE:-0}" -# Keep `brew install` from dumping its auto-update "New Formulae" wall and env -# hints on every run. Respected only by Homebrew; harmless elsewhere. +# Keep `brew install` from dumping its auto-update wall and env hints. Respected +# only by Homebrew; harmless elsewhere. export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" @@ -44,11 +49,10 @@ export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" # Defaults (every one overridable by flag or environment variable) # --------------------------------------------------------------------------- EW_BACKEND="${EW_BACKEND:-https://dashboard.edison.watch}" -EW_API_KEY="${EW_API_KEY:-}" # skip account bootstrap if set -BEEPER_ACCESS_TOKEN="${BEEPER_ACCESS_TOKEN:-}" # skip token minting if set -SERVER_NAME="${SERVER_NAME:-beeper}" # tunnel child name / gateway prefix +EW_API_KEY="${EW_API_KEY:-}" # only for the mcp-url client snippet +BEEPER_ACCESS_TOKEN="${BEEPER_ACCESS_TOKEN:-}" # skip token discovery if set +SERVER_NAME="${SERVER_NAME:-beeper}" # tunnel server name / gateway prefix DEVICE_LABEL="${DEVICE_LABEL:-$(hostname -s 2>/dev/null || echo my-mac)}" -NETWORKS="${NETWORKS:-}" # comma list: whatsapp,telegram,linkedin MCP_PKG="${MCP_PKG:-@beeper/desktop-mcp}" # the stdio proxy npx package DRY_RUN=0 @@ -58,6 +62,8 @@ JSON=0 INSTALL_DEPS=0 VERBOSE=0 NO_COLOR_FLAG=0 +NO_OPEN=0 # pass through to `edison-stdiod login --no-open` for headless auth +RELOGIN=0 # force a fresh `edison-stdiod login` even if already authorized PROG="$(basename "$0")" @@ -83,6 +89,7 @@ step() { printf '%s%s>>%s %s%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET" "$C_BOLD" "$*$C ok() { printf ' %s+%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2; } info() { printf ' %s-%s %s%s%s\n' "$C_GREY" "$C_RESET" "$C_DIM" "$*" "$C_RESET" >&2; } warn() { printf ' %s!%s %s%s%s\n' "$C_YELLOW" "$C_RESET" "$C_YELLOW" "$*" "$C_RESET" >&2; } +todo() { printf ' %saction:%s %s\n' "$C_CYAN" "$C_RESET" "$*" >&2; } vlog() { [ "$VERBOSE" -eq 1 ] && printf ' %sdebug: %s%s\n' "$C_GREY" "$*" "$C_RESET" >&2 || true; } die() { printf '%s%sx error:%s %s\n' "$C_BOLD" "$C_RED" "$C_RESET" "$1" >&2 @@ -97,12 +104,6 @@ run() { "$@" } -# capture CMD... - like run but returns stdout; suppressed under --dry-run. -capture() { - if [ "$DRY_RUN" -eq 1 ]; then printf ' %swould run:%s %s%s%s\n' "$C_CYAN" "$C_RESET" "$C_DIM" "$*" "$C_RESET" >&2; return 0; fi - "$@" -} - need_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found" "${2:-install $1 and retry}" } @@ -114,14 +115,14 @@ confirm() { printf '%s [y/N] ' "$1" >&2; read -r ans; [ "$ans" = "y" ] || [ "$ans" = "Y" ] } -# macOS is the supported target. The binary also carries a Linux (systemd -# --user) path, so we allow Linux with a warning instead of blocking, and let -# `edison-stdiod install` report any capability gap itself. Windows is out. +# macOS is the supported target because Beeper's MCP endpoint lives in the macOS +# Desktop app. stdiod also carries a Linux (systemd --user) supervisor path, so +# we allow Linux with a warning and let `edison-stdiod install` report any gap. require_supported_platform() { case "$(uname -s)" in Darwin) ;; - Linux) warn "Linux support in edison-stdiod is experimental (needs a systemd --user session); macOS is the supported target";; - *) die "unsupported platform: $(uname -s)" "macOS is supported; Linux is experimental; see stdiod/README.md";; + Linux) warn "Linux is experimental: the stdiod supervisor needs a systemd --user session, and Beeper's MCP is macOS-Desktop-only, so the child will have nothing to reach";; + *) die "unsupported platform: $(uname -s)" "macOS is supported; see stdiod/README.md";; esac } @@ -136,7 +137,8 @@ parse_flags() { --beeper-token) BEEPER_ACCESS_TOKEN="$2"; shift 2;; --server-name) SERVER_NAME="$2"; shift 2;; --device-label) DEVICE_LABEL="$2"; shift 2;; - --networks) NETWORKS="$2"; shift 2;; + --no-open) NO_OPEN=1; shift;; + --relogin) RELOGIN=1; shift;; --dry-run) DRY_RUN=1; shift;; -y|--yes) ASSUME_YES=1; shift;; --interactive) INTERACTIVE=1; shift;; @@ -157,8 +159,6 @@ parse_flags() { # --------------------------------------------------------------------------- # # ensure_tool -# -# Guarantees is on PATH, or explains exactly how to get it. Behavior: # - already present -> no-op # - --dry-run -> preview the install command, never fail # - no consent to auto-install -> fail fast with @@ -173,20 +173,12 @@ ensure_tool() { info "dep '$cmd' missing; would install via: $*" return 0 fi - - # No consent to auto-install at all: fail fast with the manual command. if [ "$INSTALL_DEPS" -eq 0 ] && [ "$INTERACTIVE" -eq 0 ]; then die "'$cmd' is not installed" "$fix" fi - - # Consent exists; confirm intent (confirm() auto-passes with --yes, prompts - # under --interactive, and refuses non-interactively without --yes). confirm "'$cmd' is missing. Install it now via: $*" \ || die "declined; '$cmd' not installed" "$fix" - - # Validate the installer itself is available before invoking it. command -v "$1" >/dev/null 2>&1 || die "cannot auto-install '$cmd': '$1' not found" "$fix" - step "installing '$cmd' via: $*" "$@" || die "auto-install of '$cmd' failed" "$fix" command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' still not on PATH after install" "$fix" @@ -200,26 +192,22 @@ ensure_deps() { ensure_tool npx \ "install Node (brew install node) or re-run with --install-deps" \ brew install --quiet node - ensure_tool beeper \ - "run: brew install beeper/tap/cli (or re-run with --install-deps)" \ - brew install --quiet beeper/tap/cli ensure_tool edison-stdiod \ "run: cargo install --path crates/edison-stdiod (or re-run with --install-deps)" \ cargo install --path "$stdiod_src" - if [ "$DRY_RUN" -eq 1 ]; then info "deps: preview only (nothing was installed)" else - ok "npx, beeper, edison-stdiod all present" + ok "npx and edison-stdiod present" fi } # --------------------------------------------------------------------------- -# Step 2: headless Beeper Server +# Step A: Beeper Desktop app + its MCP endpoint # --------------------------------------------------------------------------- # Echo the first reachable Beeper Desktop API base URL, or nothing (exit 1). -# The CLI scans ports 23373-23378 on 127.0.0.1/localhost; the server may also -# bind IPv6 ([::1]). BEEPER_API_URL overrides the probe. +# The app answers on 127.0.0.1:23373 by default; it may also bind IPv6 ([::1]) +# or scan 23373-23378. BEEPER_API_URL overrides the probe. beeper_api_base() { local wk="/.well-known/oauth-authorization-server" code h p url if [ -n "${BEEPER_API_URL:-}" ]; then @@ -236,52 +224,44 @@ beeper_api_base() { return 1 } -ensure_beeper_server() { - step "Beeper Server (headless)" - local base +ensure_beeper_desktop() { + step "Beeper Desktop MCP endpoint" if [ "$DRY_RUN" -eq 1 ]; then - info "would ensure the headless Beeper Server is running (browser auth on first setup)" - run beeper setup --server --install + info "would probe 127.0.0.1:23373-23378 for the Beeper Desktop API" return 0 fi - # Probe the real Desktop API, not `beeper status` (which exits 0 even with no - # server configured, so it used to skip setup and leave nothing listening). + local base if base="$(beeper_api_base)"; then - ok "Desktop API reachable at $base" + ok "Beeper Desktop API reachable at $base" return 0 fi - info "installing/starting the headless server (a browser opens once to authorize your Beeper account)" - run beeper setup --server --install - if base="$(beeper_api_base)"; then - ok "Desktop API reachable at $base" - else - die "the Beeper Desktop API is not reachable on 23373-23378 after setup" \ - "finish the browser authorization opened by 'beeper setup --server --install', then re-run: $PROG install" - fi + warn "the Beeper Desktop API is not answering on 23373-23378" + todo "open the Beeper Desktop app, then Settings > Developers > MCP and enable it" + todo "link the chats you want (WhatsApp / Telegram / ...) inside the Beeper app" + info "the headless 'beeper' Server does not expose MCP (github.com/beeper/cli issue #20), so the Desktop app must be running" + die "Beeper Desktop MCP endpoint unavailable" \ + "enable MCP in Beeper Desktop and keep the app running, then re-run: $PROG install" } # --------------------------------------------------------------------------- -# Step 3: Beeper access token (for the stdio MCP proxy child) +# Step B: Beeper access token (for the stdio MCP proxy child) # --------------------------------------------------------------------------- -# Show only a safe fingerprint of a secret, never the secret itself. mask_token() { local s="$1" [ "${#s}" -le 12 ] && { printf '' "${#s}"; return; } printf '%s...%s (len %s)' "${s:0:6}" "${s: -4}" "${#s}" } -# discover_beeper_token - reuse the token the `beeper` CLI already holds after -# `beeper setup`, so no GUI "Approved connections" step is needed. Beeper has no -# headless mint (OAuth is authorization_code + PKCE, which needs a browser), but -# the stored session token is a valid Desktop API bearer. We stay format- -# agnostic: gather candidate strings from the CLI config dir and the macOS -# Keychain, then keep the first that authenticates against the local OAuth -# userinfo endpoint. Prints the working token to stdout; diagnostics to stderr. +# discover_beeper_token - find a Desktop API bearer the Beeper app already holds, +# so you can paste it into the dashboard without minting a fresh one by hand. +# Beeper has no headless mint (OAuth is authorization_code + PKCE), so we gather +# candidate strings from the Beeper config dir and the macOS Keychain and keep +# the first that authenticates against the local OAuth userinfo endpoint. Prints +# the working token to stdout; diagnostics to stderr. discover_beeper_token() { local base uinfo="" if ! base="$(beeper_api_base)"; then warn "the Beeper Desktop API is not reachable on 23373-23378" - warn "run 'beeper setup --server --install' and finish the browser authorization first" return 1 fi uinfo="$(curl -s -m 4 "$base/.well-known/oauth-authorization-server" 2>/dev/null \ @@ -289,16 +269,15 @@ discover_beeper_token() { | grep -oE 'https?://[^"]+' | head -1)" [ -z "$uinfo" ] && uinfo="$base/oauth/userinfo" - # Resolve the CLI config dir (`beeper config path` returns ~/.beeper/config.json). - local cp dirs="$HOME/.beeper" d - cp="$(beeper config path 2>/dev/null || true)" - if [ -n "$cp" ]; then - if [ -d "$cp" ]; then dirs="$cp $dirs"; else dirs="$(dirname "$cp") $dirs"; fi + local dirs="$HOME/.beeper" d cp + if command -v beeper >/dev/null 2>&1; then + cp="$(beeper config path 2>/dev/null || true)" + if [ -n "$cp" ]; then + if [ -d "$cp" ]; then dirs="$cp $dirs"; else dirs="$(dirname "$cp") $dirs"; fi + fi fi - # Primary: the target files store the bearer verbatim as "accessToken" - # (~/.beeper/targets/.json). Extract those first and remember the first - # one as the canonical token to fall back on if live validation is flaky. + # Primary: target files store the bearer verbatim as "accessToken". local explicit="" f t cands="" for d in $dirs; do [ -d "$d" ] || continue @@ -312,8 +291,7 @@ $(find "$d" -type f -name '*.json' 2>/dev/null) EOF2 done - # Secondary: token-shaped strings from small JSON files only (skip the DBs, - # logs, and the bundled server binary so real tokens are not crowded out). + # Secondary: token-shaped strings from small JSON files only. for d in $dirs; do [ -d "$d" ] || continue cands="$cands @@ -331,7 +309,6 @@ $kc" done fi - # Validate candidates against userinfo; use the first that authenticates. local tok code while IFS= read -r tok; do [ -z "$tok" ] && continue @@ -341,195 +318,144 @@ $kc" $cands EOF - # None validated live: if we found an explicit accessToken, trust it (the - # userinfo probe can be strict about scopes; the child will surface a real - # auth error if it is genuinely wrong). if [ -n "$explicit" ]; then - warn "using the Beeper CLI accessToken without a live userinfo check" + warn "using the Beeper accessToken without a live userinfo check" printf '%s' "$explicit"; return 0 fi return 1 } -# Precedence: explicit token > reuse the CLI's session token > manual step. -ensure_beeper_token() { - step "Beeper access token" - if [ -n "$BEEPER_ACCESS_TOKEN" ]; then - ok "using supplied token" - return 0 - fi +# --------------------------------------------------------------------------- +# Step 2 + 3: authorize this device and supervise the daemon +# --------------------------------------------------------------------------- +stdiod_config() { printf '%s' "${EDISON_STDIOD_CONFIG:-$HOME/.config/edison-stdiod/config.toml}"; } + +# True when config already holds a client credential from a prior browser login. +stdiod_logged_in() { + local f; f="$(stdiod_config)" + [ -f "$f" ] && grep -q 'client_access_token' "$f" 2>/dev/null +} + +ensure_stdiod_auth() { + step "Edison device authorization (browser)" if [ "$DRY_RUN" -eq 1 ]; then - info "would reuse the Beeper CLI session token, else require --beeper-token" - BEEPER_ACCESS_TOKEN="dry-run-placeholder-token" + run edison-stdiod login --backend "$EW_BACKEND" return 0 fi - local tok - if tok="$(discover_beeper_token)" && [ -n "$tok" ]; then - BEEPER_ACCESS_TOKEN="$tok" - ok "reusing the Beeper CLI's authorized session token, no GUI needed [$(mask_token "$tok")]" + if [ "$RELOGIN" -eq 0 ] && stdiod_logged_in; then + ok "already authorized on this device (client credential present in $(stdiod_config))" return 0 fi - # Fallback: no reusable token and none supplied. Guide the one-time manual step. - warn "could not reuse a Beeper CLI token; is the Beeper Server running and logged in?" - warn "otherwise create one once in Beeper Desktop: Settings > Developers > Beeper Desktop API," - warn "then Approved connections > + to copy a token, and re-run with --beeper-token" - die "no Beeper access token available" \ - "pass --beeper-token (or set BEEPER_ACCESS_TOKEN); the re-run is fast, deps are already installed" -} - -# --------------------------------------------------------------------------- -# Step 4a: Edison Watch account + API key -# --------------------------------------------------------------------------- -ensure_ew_api_key() { - if [ -z "$EW_API_KEY" ]; then - die "no Edison Watch API key provided" \ - "sign in at ${EW_BACKEND}, create an API key, then re-run with --ew-api-key edison_..." - fi - if [ "$DRY_RUN" -eq 1 ]; then - ok "edison account: using supplied API key" - return 0 + # `edison-stdiod login` runs the OAuth device flow: it prints a URL to approve + # (and opens a browser unless --no-open), then stores a scoped client + # credential. No API key, no step-up token. + local args=(login --backend "$EW_BACKEND") + [ "$NO_OPEN" -eq 1 ] && args+=(--no-open) + info "a browser opens to approve this device; on a headless box pass --no-open and open the printed URL elsewhere" + if ! run edison-stdiod "${args[@]}"; then + die "edison-stdiod login failed" "check --ew-backend (${EW_BACKEND}) and complete the browser approval, then re-run: $PROG install" fi - # Validate the key up front so a bad key fails here with a clear message, - # not mid-flow inside 'edison-stdiod server add'. - local code - code="$(curl -s -o /dev/null -w '%{http_code}' -m 15 --connect-timeout 5 \ - -H "Authorization: Bearer ${EW_API_KEY}" "${EW_BACKEND%/}/api/v1/servers" 2>/dev/null || true)" - case "$code" in - 401|403) die "Edison rejected the API key at ${EW_BACKEND} (http ${code}: invalid or inactive)" \ - "check the key is active and from this environment; for demo pass --ew-backend https://demo-dashboard.edison.watch";; - 000) warn "could not reach ${EW_BACKEND} to validate the key; continuing";; - *) ok "edison account: API key accepted by ${EW_BACKEND}";; - esac + ok "device authorized to ${EW_BACKEND}" } -# --------------------------------------------------------------------------- -# Step 4b: supervise the tunnel daemon and register the Beeper child -# --------------------------------------------------------------------------- -wire_tunnel() { - step "Edison tunnel (stdiod daemon)" - # Each step is wrapped so a failure yields a clean, actionable message - # instead of a raw daemon backtrace plus a set -e abort mid-flow. - if ! run edison-stdiod login --backend "$EW_BACKEND" --api-key "$EW_API_KEY" --device-label "$DEVICE_LABEL"; then - die "edison-stdiod login failed" "check --ew-backend and --ew-api-key, then re-run: $PROG install" - fi +ensure_stdiod_supervised() { + step "Edison tunnel daemon (supervisor)" if ! run edison-stdiod install; then die "edison-stdiod install could not register the supervisor unit" \ "macOS needs no privileges; Linux needs a logged-in systemd --user session. Fix that, then re-run: $PROG install" fi + ok "daemon installed and supervised" +} - # Use the --arg=VALUE form: clap rejects a hyphen-leading value in the space - # form ('--arg -y' is read as an unknown flag), so '--arg=-y'. +# --------------------------------------------------------------------------- +# Step 4: submit the Beeper stdio server for approval +# --------------------------------------------------------------------------- +# Under device authorization, `server add` submits a request scoped to this +# exact device (POST /api/v1/client/mcp-requests). An org admin approves it once +# in the dashboard; it does not run until then. `server list` shows only +# approved servers bound to this device, so we use it as the idempotency check. +submit_beeper_server() { + step "Submitting the Beeper server" if [ "$DRY_RUN" -eq 1 ]; then run edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ --command npx --arg=-y --arg="$MCP_PKG" return 0 fi - # Idempotent on this device. if edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then - ok "tunnel child '$SERVER_NAME' already registered on this device" + ok "server '$SERVER_NAME' is already approved and bound to this device" return 0 fi - # Capture with '&& rc=0 || rc=$?' so a non-zero add does not trip set -e - # (a bare 'out=$(cmd)' assignment failing would abort the script silently). + # Capture with '&& rc=0 || rc=$?' so a non-zero add does not trip set -e. + # Use --arg=VALUE: clap rejects a hyphen-leading value in the space form. local out rc out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ --command npx --arg=-y --arg="$MCP_PKG" 2>&1)" && rc=0 || rc=$? - # A name is unique per org: a 409 means it exists under another device (a - # stale registration). Remove it (org-level, admin-only) and re-add here. - if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE 'already exists|CONFLICT|409'; then - warn "'$SERVER_NAME' already exists in this org (stale/other device); re-registering it here" - edison-stdiod server remove "$SERVER_NAME" >/dev/null 2>&1 || true - out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ - --command npx --arg=-y --arg="$MCP_PKG" 2>&1)" && rc=0 || rc=$? - fi + printf '%s\n' "$out" | grep -viE '^\s*$' >&2 || true if [ "$rc" -ne 0 ]; then - printf '%s\n' "$out" >&2 - if printf '%s' "$out" | grep -qi 'STEP_UP_REQUIRED\|fresh re-login\|re-authenticate'; then - die "adding a local server is gated behind interactive step-up re-auth (by design)" \ - "add '$SERVER_NAME' once in the ${EW_BACKEND} dashboard (targets this device, clears the 5-min re-auth modal), then re-run: $PROG install. On non-release backends an admin can also set STEP_UP_BYPASS_EMAIL_DOMAINS." + if printf '%s' "$out" | grep -qiE 'already (exists|submitted|pending)|duplicate'; then + ok "a request for '$SERVER_NAME' is already pending; approve it in the dashboard" + return 0 fi die "edison-stdiod server add failed for '$SERVER_NAME'" \ - "if it still conflicts, your key may lack admin (remove needs it); pass --server-name or delete '$SERVER_NAME' in the dashboard, then re-run: $PROG install" + "check 'edison-stdiod status' shows the daemon connected, then re-run: $PROG install" fi - ok "tunnel child '$SERVER_NAME' registered" + ok "submitted '$SERVER_NAME' (npx $MCP_PKG) for approval" + todo "approve '$SERVER_NAME' in the Edison dashboard: ${EW_BACKEND%/} -> Servers / requests" } # --------------------------------------------------------------------------- -# Step 5: bind the Beeper token to the child +# Step C: hand the Beeper token to the operator for the dashboard # --------------------------------------------------------------------------- -# `edison-stdiod server add` carries no env, so we push BEEPER_ACCESS_TOKEN -# separately. The route is the backend's confirmed stdio_tunnel env endpoint, -# `POST /api/v1/servers/{name}/env` (schema UpdateServerEnvRequest): the value -# is staged in the device's on-device env_store and the child is respawned with -# it. The endpoint is admin-only, so --ew-api-key must belong to an org admin. -# A failure here is non-fatal: the tunnel and child are already set up, so we -# print the manual step and let the rest of the install finish. -bind_beeper_token() { - step "Binding Beeper token to the tunnel child" - local path="${EW_SERVER_ENV_PATH:-/api/v1/servers/${SERVER_NAME}/env}" - local url="${EW_BACKEND}${path}" - if [ "$DRY_RUN" -eq 1 ]; then - run curl -X POST "$url" "(set BEEPER_ACCESS_TOKEN + base URL, respawns child)" +# The device-scoped client request carries no env, and the daemon receives each +# server's environment from the backend (a ServerEnvUpdate frame merged into the +# device's per-installation env_store). So BEEPER_ACCESS_TOKEN is set in the +# dashboard when the server is configured, not by a call from this script. We +# discover a reusable token and print it (masked) with the exact place to paste. +report_beeper_token() { + step "Beeper access token for the dashboard" + local tok="" + if [ -n "$BEEPER_ACCESS_TOKEN" ]; then + tok="$BEEPER_ACCESS_TOKEN" + ok "using the supplied token [$(mask_token "$tok")]" + elif [ "$DRY_RUN" -eq 1 ]; then + info "would discover a reusable Beeper token to paste into the dashboard" return 0 + elif tok="$(discover_beeper_token)" && [ -n "$tok" ]; then + ok "discovered a working Beeper token [$(mask_token "$tok")]" + else + warn "could not discover a Beeper token automatically" + todo "in Beeper Desktop: Settings > Developers > Beeper Desktop API > create/copy a token" + tok="" fi - # Also pass the discovered base URL: the server may bind a non-default port - # (e.g. 23374) while the proxy defaults to 23373. Send both common env names. - local api_base; api_base="$(beeper_api_base 2>/dev/null || true)" - [ -z "$api_base" ] && api_base="http://127.0.0.1:23373" - local code - code="$(curl -s -o /dev/null -w '%{http_code}' -m 30 --connect-timeout 5 -X POST "$url" \ - -H "Authorization: Bearer ${EW_API_KEY}" \ - -H "Content-Type: application/json" \ - --data "{\"env\":{\"BEEPER_ACCESS_TOKEN\":\"${BEEPER_ACCESS_TOKEN}\",\"BEEPER_API_URL\":\"${api_base}\",\"BEEPER_DESKTOP_BASE_URL\":\"${api_base}\"}}" 2>/dev/null || true)" - [ -z "$code" ] && code="000" - case "$code" in - 2*) ok "bound to child '$SERVER_NAME' and respawned"; return 0;; - 401|403) warn "not authorized to bind the token (http ${code})" - warn "the env endpoint is admin-only; --ew-api-key must belong to an org admin";; - 000) warn "could not reach ${url} (network, or daemon not connected yet)" - warn "confirm 'edison-stdiod status' shows connected, then re-run: $PROG install";; - *) warn "token bind returned http ${code} for ${url}";; - esac - warn "manual fallback: set BEEPER_ACCESS_TOKEN for server '${SERVER_NAME}' in the Edison dashboard under Servers > ${SERVER_NAME} > environment" -} - -# --------------------------------------------------------------------------- -# Chat networks -# --------------------------------------------------------------------------- -add_networks() { - [ -z "$NETWORKS" ] && return 0 - step "Linking chat networks" - # Split on commas without leaking IFS into run()'s "$*" logging. - local net nets - nets="$(printf '%s' "$NETWORKS" | tr ',' ' ')" - for net in $nets; do - [ -z "$net" ] && continue - info "adding '$net' (follow the QR / code prompt in this terminal)" - run beeper accounts add "$net" - done + todo "in the Edison dashboard, open server '$SERVER_NAME' and set env BEEPER_ACCESS_TOKEN=" + info "the daemon respawns the child with that env once it is saved" + # In --json mode the token is emitted by print_result; here we keep it off + # stdout so it is not captured by accident. + BEEPER_ACCESS_TOKEN="$tok" } # --------------------------------------------------------------------------- # Result # --------------------------------------------------------------------------- -print_mcp_url() { +print_result() { local mcp_url="${EW_BACKEND%/}/mcp" - local masked="${EW_API_KEY:0:10}..." if [ "$JSON" -eq 1 ]; then - printf '{"mcp_url":"%s","auth_header":"Authorization: Bearer %s","server":"%s","device_label":"%s"}\n' \ - "$mcp_url" "$EW_API_KEY" "$SERVER_NAME" "$DEVICE_LABEL" + printf '{"mcp_url":"%s","server":"%s","device_label":"%s","beeper_token_present":%s}\n' \ + "$mcp_url" "$SERVER_NAME" "$DEVICE_LABEL" "$([ -n "$BEEPER_ACCESS_TOKEN" ] && echo true || echo false)" return 0 fi - # stdout-gated colors so redirected/piped output stays clean and parseable. local b=$C_BOLD g=$C_GREEN d=$C_DIM r=$C_RESET [ -t 1 ] || { b=; g=; d=; r=; } printf '%smcp_url:%s %s%s%s\n' "$b" "$r" "$g" "$mcp_url" "$r" - printf '%sauth:%s Authorization: Bearer %s\n' "$b" "$r" "$masked" - printf '%sserver:%s %s (prefix: %s_*)\n' "$b" "$r" "$SERVER_NAME" "$SERVER_NAME" + printf '%sserver:%s %s (gateway prefix: %s_*)\n' "$b" "$r" "$SERVER_NAME" "$SERVER_NAME" printf '%sdevice:%s %s\n' "$b" "$r" "$DEVICE_LABEL" - # Ready-to-run snippet uses the real key so it can be pasted as-is (uncolored). - printf '\n%s# add to Claude Code:%s\n' "$d" "$r" - printf 'claude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" + if [ -n "$EW_API_KEY" ]; then + printf '\n%s# add to Claude Code (gateway auth uses your Edison API key):%s\n' "$d" "$r" + printf 'claude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" + else + printf '\n%s# the AI client authenticates to the gateway with your Edison API key or OAuth;%s\n' "$d" "$r" + printf '%s# pass --ew-api-key to print a ready-to-run `claude mcp add` snippet.%s\n' "$d" "$r" + fi } # =========================================================================== @@ -537,68 +463,63 @@ print_mcp_url() { # =========================================================================== cmd_install() { ensure_deps - ensure_beeper_server - ensure_beeper_token - ensure_ew_api_key - wire_tunnel - bind_beeper_token - add_networks - printf '\n%s%s== install complete ==%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" >&2 - print_mcp_url + ensure_beeper_desktop + ensure_stdiod_auth + ensure_stdiod_supervised + submit_beeper_server + report_beeper_token + printf '\n%s%s== Edison side wired ==%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" >&2 + log "remaining human steps are printed above as 'action:' lines (approve the server, set the token)." + print_result } cmd_doctor() { step "Doctor" local allgood=1 - for c in npx beeper edison-stdiod; do + for c in npx edison-stdiod; do if command -v "$c" >/dev/null 2>&1; then ok "$c"; else warn "$c missing"; allgood=0; fi done - if beeper status >/dev/null 2>&1; then ok "beeper server reachable"; else warn "beeper server not reachable"; allgood=0; fi + if beeper_api_base >/dev/null 2>&1; then ok "Beeper Desktop API reachable"; else warn "Beeper Desktop API not reachable (enable MCP in Beeper Desktop)"; allgood=0; fi + if stdiod_logged_in; then ok "device authorized to Edison"; else warn "not authorized (run: $PROG install)"; allgood=0; fi if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod status >/dev/null 2>&1; then ok "stdiod daemon connected"; else warn "stdiod daemon not running (run: $PROG install)"; allgood=0; fi - [ "$allgood" -eq 1 ] && ok "all good" || die "some checks failed (see above)" "$PROG install --install-deps" + if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + ok "server '$SERVER_NAME' approved on this device"; else warn "server '$SERVER_NAME' not approved yet (submit + approve in dashboard)"; fi + [ "$allgood" -eq 1 ] && ok "core checks passed" || die "some checks failed (see above)" "$PROG install --install-deps" } cmd_status() { need_cmd edison-stdiod run edison-stdiod status - command -v beeper >/dev/null 2>&1 && run beeper status || true + local base; base="$(beeper_api_base 2>/dev/null || true)" + [ -n "$base" ] && ok "Beeper Desktop API: $base" || warn "Beeper Desktop API not reachable" } -cmd_network() { - local verb="${ARGS[0]:-}" - case "$verb" in - add) NETWORKS="${ARGS[1]:-}"; [ -z "$NETWORKS" ] && die "network name required" "$PROG network add whatsapp"; add_networks;; - list) run beeper accounts list;; - *) die "unknown 'network' verb: ${verb:-}" "$PROG network add whatsapp | $PROG network list";; - esac -} - -cmd_mcp_url() { ensure_ew_api_key; print_mcp_url; } - cmd_token() { - step "Discovering a Beeper access token (headless)" + step "Discovering a Beeper access token" if [ -n "$BEEPER_ACCESS_TOKEN" ]; then ok "a token is already supplied [$(mask_token "$BEEPER_ACCESS_TOKEN")]" return 0 fi local tok if tok="$(discover_beeper_token)" && [ -n "$tok" ]; then - ok "found a working token by reusing the Beeper CLI session [$(mask_token "$tok")]" - info "'$PROG install' will use this automatically; no --beeper-token needed" + ok "found a working token [$(mask_token "$tok")]" + todo "set it as BEEPER_ACCESS_TOKEN on server '$SERVER_NAME' in the Edison dashboard" return 0 fi - warn "no reusable token found (is the Beeper Server running and logged in?)" - die "no Beeper access token discovered" "pass --beeper-token , or run 'beeper setup --server --install' first" + warn "no reusable token found (is Beeper Desktop running with MCP enabled?)" + die "no Beeper access token discovered" "create one in Beeper Desktop > Settings > Developers, or pass --beeper-token " } +cmd_mcp_url() { print_result; } + cmd_uninstall() { - confirm "remove the stdiod supervisor unit and Beeper child?" || die "aborted" "" + confirm "withdraw the '$SERVER_NAME' request/server and remove the stdiod supervisor unit?" || die "aborted" "" command -v edison-stdiod >/dev/null 2>&1 && { run edison-stdiod server remove "$SERVER_NAME" || true run edison-stdiod uninstall } - log "uninstall complete. Beeper Server was left running; remove it with: beeper uninstall server" + log "uninstall complete. Approved-server removal may need a dashboard/admin action; Beeper Desktop was left untouched." } # =========================================================================== @@ -608,27 +529,28 @@ usage() { cat >&2 < [flags] Commands: - install Full flow: deps, Beeper Server, tunnel, register + bind Beeper, print MCP URL - doctor Check prerequisites and current state (read-only) - status Show stdiod daemon and Beeper Server status - network add Link a chat network (whatsapp | telegram | linkedin | ...) - network list List linked chat networks - mcp-url Print the Edison MCP URL and client snippet - token Discover a reusable Beeper token from the CLI session (headless, no GUI) - uninstall Remove the tunnel child and supervisor unit + install Deps, Beeper-Desktop check, device auth, supervise daemon, submit Beeper server + doctor Check prerequisites and current state (read-only) + status Show stdiod daemon + Beeper Desktop API status + token Discover a reusable Beeper token to paste into the dashboard + mcp-url Print the Edison MCP URL and client snippet + uninstall Withdraw the server and remove the supervisor unit Common flags (also settable as UPPER_SNAKE env vars): - --ew-backend URL Edison backend (EW_BACKEND, default $EW_BACKEND) - --ew-api-key KEY Edison API key (EW_API_KEY) required for install/mcp-url - --beeper-token TOK Beeper access token (BEEPER_ACCESS_TOKEN) skips CLI minting - --networks a,b,c Link these after wiring (NETWORKS) - --install-deps Consent to auto-install missing deps (npx/beeper/edison-stdiod - via brew/cargo). Confirms first unless --yes; validates each - landed on PATH. --dry-run previews installs without running them. + --ew-backend URL Edison backend (EW_BACKEND, default $EW_BACKEND) + --ew-api-key KEY Edison API key for the client snippet only (EW_API_KEY) + --beeper-token TOK Beeper access token (BEEPER_ACCESS_TOKEN) skips discovery + --no-open Headless device auth: print the approval URL, do not open a browser + --relogin Force a fresh device authorization even if already authorized + --install-deps Consent to auto-install missing deps (npx/edison-stdiod). + Confirms first unless --yes; validates each landed on PATH. --dry-run Print what would run; change nothing --yes Skip confirmations (agents pass this) --interactive Allow interactive prompts as a fallback @@ -638,17 +560,14 @@ Common flags (also settable as UPPER_SNAKE env vars): -h, --help This help Examples: - # Non-interactive, agent-friendly: everything supplied up front - $PROG install --install-deps --yes \\ - --ew-api-key ew_live_abc --beeper-token bpr_xyz \\ - --networks whatsapp,telegram,linkedin + # Agent-friendly: install deps and wire the Edison side, headless device auth + $PROG install --install-deps --yes --no-open --ew-backend https://demo-dashboard.edison.watch # Preview without changing anything - $PROG install --ew-api-key ew_live_abc --dry-run + $PROG install --dry-run - # Link WhatsApp later, then fetch the URL as JSON for a config file - $PROG network add whatsapp - $PROG mcp-url --ew-api-key ew_live_abc --json + # Discover a Beeper token to paste into the dashboard + $PROG token Exit codes: 0 ok, 1 error (message + fix printed to stderr). EOF @@ -656,15 +575,13 @@ EOF subcmd_help() { case "$1" in - install) log "install - run the full wiring flow. Idempotent; safe to re-run." - log " needs: --ew-api-key (or EW_API_KEY). Optional: --beeper-token, --networks, --install-deps, --yes, --dry-run." - log " example: $PROG install --install-deps --yes --ew-api-key ew_live_abc --networks whatsapp";; - network) log "network add | network list" - log " example: $PROG network add telegram";; - mcp-url) log "mcp-url - print the gateway URL + client snippet. needs --ew-api-key. supports --json.";; - status) log "status - show stdiod daemon + Beeper Server status.";; + install) log "install - wire the Edison side and print remaining human steps. Idempotent; safe to re-run." + log " optional: --ew-backend, --no-open, --install-deps, --yes, --dry-run.";; + token) log "token - discover a reusable Beeper token to paste into the dashboard.";; + mcp-url) log "mcp-url - print the gateway URL + client snippet. pass --ew-api-key for a ready-to-run snippet. supports --json.";; + status) log "status - show stdiod daemon + Beeper Desktop API status.";; doctor) log "doctor - verify prerequisites and current state (read-only).";; - uninstall)log "uninstall - remove the tunnel child and supervisor unit. pass --yes to skip the prompt.";; + uninstall)log "uninstall - withdraw the server and remove the supervisor unit. pass --yes to skip the prompt.";; *) usage;; esac } @@ -675,10 +592,6 @@ subcmd_help() { main() { local cmd="${1:-}"; shift || true ARGS=() - # Support "network add" as a two-word command. - if [ "$cmd" = "network" ]; then - ARGS+=("${1:-}"); shift || true - fi parse_flags "$@" || { init_colors; subcmd_help "$cmd"; exit 0; } init_colors @@ -686,9 +599,8 @@ main() { install) cmd_install;; doctor) cmd_doctor;; status) cmd_status;; - network) cmd_network;; - mcp-url) cmd_mcp_url;; token) cmd_token;; + mcp-url) cmd_mcp_url;; uninstall) cmd_uninstall;; ""|help|-h|--help) usage;; *) die "unknown command: $cmd" "run '$PROG --help' for the command list";; From 7197c5996f031ee42de860c58f8caebe2b3402c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 10:38:45 +0000 Subject: [PATCH 18/21] install-beeper: address thermo-nuclear + shellcheck review - parse_flags: guard value-taking flags via needval() so a missing value dies through the error contract instead of set -u's raw "unbound variable", and reject a flag-looking value so a forgotten arg cannot swallow the next flag. - discover_beeper_token: iterate config dirs as an array so macOS "Application Support" (spaced) paths survive; cap the generic scrape and bound live validation to 20 candidates at a 3s timeout (was up to 120x5s); dedupe candidates preserving order; factor out beeper_userinfo_endpoint. - Extract server_registered(): one definition of the idempotency check (jq precise name match, grep fallback), used by install and doctor. - Reject stray positional args instead of silently swallowing them. - Beeper Desktop check is non-fatal now: wire the Edison side and print the action rather than stopping at the prerequisite. - Warn when the saved stdiod backend differs from --ew-backend. - confirm(): local ans. Clarify DEVICE_LABEL is display-only. Convert two `&& || ` chains to if/else. Drop the doubled find redirect. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 144 +++++++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 41 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 50867c1..2e5c03d 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -52,6 +52,8 @@ EW_BACKEND="${EW_BACKEND:-https://dashboard.edison.watch}" EW_API_KEY="${EW_API_KEY:-}" # only for the mcp-url client snippet BEEPER_ACCESS_TOKEN="${BEEPER_ACCESS_TOKEN:-}" # skip token discovery if set SERVER_NAME="${SERVER_NAME:-beeper}" # tunnel server name / gateway prefix +# Display label for this script's own output only. It does NOT set the stdiod +# device record: `edison-stdiod login` issues the device identity server-side. DEVICE_LABEL="${DEVICE_LABEL:-$(hostname -s 2>/dev/null || echo my-mac)}" MCP_PKG="${MCP_PKG:-@beeper/desktop-mcp}" # the stdio proxy npx package @@ -109,6 +111,7 @@ need_cmd() { } confirm() { + local ans [ "$ASSUME_YES" -eq 1 ] && return 0 [ "$INTERACTIVE" -eq 0 ] && die "refusing to run a confirming action non-interactively: $1" \ "pass --yes to proceed, or --dry-run to preview" @@ -129,14 +132,26 @@ require_supported_platform() { # --------------------------------------------------------------------------- # Flag parsing (shared across subcommands; unknown flags fail fast) # --------------------------------------------------------------------------- +# Guard a value-taking flag before dereferencing its value. Args: remaining +# count ($#), the flag, and the candidate value. Routes through die() (our error +# contract) instead of `set -u`'s raw "unbound variable" when the value is +# missing (`install --ew-backend`), and rejects a flag-looking value so a +# forgotten argument does not silently swallow the next flag +# (`install --ew-backend --no-open`). +needval() { + local n="$1" flag="$2" val="${3:-}" + { [ "$n" -ge 2 ] && [ "${val#-}" = "$val" ]; } \ + || die "flag '$flag' needs a value" "example: $flag " +} + parse_flags() { while [ $# -gt 0 ]; do case "$1" in - --ew-backend) EW_BACKEND="$2"; shift 2;; - --ew-api-key) EW_API_KEY="$2"; shift 2;; - --beeper-token) BEEPER_ACCESS_TOKEN="$2"; shift 2;; - --server-name) SERVER_NAME="$2"; shift 2;; - --device-label) DEVICE_LABEL="$2"; shift 2;; + --ew-backend) needval $# "$1" "${2:-}"; EW_BACKEND="$2"; shift 2;; + --ew-api-key) needval $# "$1" "${2:-}"; EW_API_KEY="$2"; shift 2;; + --beeper-token) needval $# "$1" "${2:-}"; BEEPER_ACCESS_TOKEN="$2"; shift 2;; + --server-name) needval $# "$1" "${2:-}"; SERVER_NAME="$2"; shift 2;; + --device-label) needval $# "$1" "${2:-}"; DEVICE_LABEL="$2"; shift 2;; --no-open) NO_OPEN=1; shift;; --relogin) RELOGIN=1; shift;; --dry-run) DRY_RUN=1; shift;; @@ -224,6 +239,9 @@ beeper_api_base() { return 1 } +# Non-fatal: if Beeper Desktop is not answering we still wire the automatable +# Edison side and print the exact action, so `install` makes progress instead of +# stopping the operator at the first prerequisite. ensure_beeper_desktop() { step "Beeper Desktop MCP endpoint" if [ "$DRY_RUN" -eq 1 ]; then @@ -239,8 +257,7 @@ ensure_beeper_desktop() { todo "open the Beeper Desktop app, then Settings > Developers > MCP and enable it" todo "link the chats you want (WhatsApp / Telegram / ...) inside the Beeper app" info "the headless 'beeper' Server does not expose MCP (github.com/beeper/cli issue #20), so the Desktop app must be running" - die "Beeper Desktop MCP endpoint unavailable" \ - "enable MCP in Beeper Desktop and keep the app running, then re-run: $PROG install" + warn "continuing to wire the Edison side; the Beeper child stays idle until this is enabled" } # --------------------------------------------------------------------------- @@ -252,34 +269,43 @@ mask_token() { printf '%s...%s (len %s)' "${s:0:6}" "${s: -4}" "${#s}" } -# discover_beeper_token - find a Desktop API bearer the Beeper app already holds, -# so you can paste it into the dashboard without minting a fresh one by hand. -# Beeper has no headless mint (OAuth is authorization_code + PKCE), so we gather -# candidate strings from the Beeper config dir and the macOS Keychain and keep -# the first that authenticates against the local OAuth userinfo endpoint. Prints -# the working token to stdout; diagnostics to stderr. -discover_beeper_token() { - local base uinfo="" - if ! base="$(beeper_api_base)"; then - warn "the Beeper Desktop API is not reachable on 23373-23378" - return 1 - fi +# Resolve the Beeper OAuth userinfo endpoint from the well-known document, with +# a sensible default. Echoes the URL; empty on no reachable API. +beeper_userinfo_endpoint() { + local base uinfo + base="$(beeper_api_base)" || return 1 uinfo="$(curl -s -m 4 "$base/.well-known/oauth-authorization-server" 2>/dev/null \ | grep -oE '"userinfo_endpoint"[[:space:]]*:[[:space:]]*"[^"]+"' \ | grep -oE 'https?://[^"]+' | head -1)" [ -z "$uinfo" ] && uinfo="$base/oauth/userinfo" + printf '%s' "$uinfo" +} + +# discover_beeper_token - find a Desktop API bearer the Beeper app already holds, +# so you can paste it into the dashboard without minting a fresh one by hand. +# Beeper has no headless mint (OAuth is authorization_code + PKCE). The reliable +# invariant is that Beeper stores the bearer verbatim as "accessToken"; a capped +# generic scrape and the Keychain are best-effort fallbacks. Candidates are +# validated against the userinfo endpoint, first one that returns 200 wins. +# Prints the working token to stdout; diagnostics to stderr. +discover_beeper_token() { + local uinfo + uinfo="$(beeper_userinfo_endpoint)" || { warn "the Beeper Desktop API is not reachable on 23373-23378"; return 1; } - local dirs="$HOME/.beeper" d cp + # Search the Beeper config dir(s). Use an array so paths with spaces (the + # macOS "Application Support" norm, or a spaced $HOME) survive intact. + local dirs=("$HOME/.beeper") d cp if command -v beeper >/dev/null 2>&1; then cp="$(beeper config path 2>/dev/null || true)" if [ -n "$cp" ]; then - if [ -d "$cp" ]; then dirs="$cp $dirs"; else dirs="$(dirname "$cp") $dirs"; fi + if [ -d "$cp" ]; then dirs=("$cp" "${dirs[@]}"); else dirs=("$(dirname "$cp")" "${dirs[@]}"); fi fi fi - # Primary: target files store the bearer verbatim as "accessToken". + # Primary (targeted): target files store the bearer verbatim as "accessToken". + # Remember the first as the canonical fallback if live validation is flaky. local explicit="" f t cands="" - for d in $dirs; do + for d in "${dirs[@]}"; do [ -d "$d" ] || continue while IFS= read -r f; do [ -n "$f" ] || continue @@ -291,12 +317,12 @@ $(find "$d" -type f -name '*.json' 2>/dev/null) EOF2 done - # Secondary: token-shaped strings from small JSON files only. - for d in $dirs; do + # Secondary (best-effort, capped): token-shaped strings from small JSON files. + for d in "${dirs[@]}"; do [ -d "$d" ] || continue cands="$cands -$(find "$d" -type f -name '*.json' -size -1M 2>/dev/null -exec cat {} + 2>/dev/null \ - | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 120)" +$(find "$d" -type f -name '*.json' -size -1M -exec cat {} + 2>/dev/null \ + | grep -oE '[A-Za-z0-9._-]{24,}' | sort -u | head -n 40)" done # Best-effort Keychain fallback. @@ -309,13 +335,17 @@ $kc" done fi - local tok code + # Validate candidates (deduped, order preserved so accessToken hits first). + # Bounded so a bad guess set cannot turn this into minutes of silent curls. + local tok code tried=0 max=20 while IFS= read -r tok; do [ -z "$tok" ] && continue - code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 -H "Authorization: Bearer $tok" "$uinfo" 2>/dev/null || true)" + tried=$((tried + 1)) + if [ "$tried" -gt "$max" ]; then warn "checked $max candidate tokens without a live match; stopping"; break; fi + code="$(curl -s -o /dev/null -w '%{http_code}' -m 3 -H "Authorization: Bearer $tok" "$uinfo" 2>/dev/null || true)" if [ "$code" = "200" ]; then printf '%s' "$tok"; return 0; fi done </dev/null 2>&1 || return 1 + local json; json="$(edison-stdiod server list --json 2>/dev/null || true)" + [ -n "$json" ] || return 1 + if command -v jq >/dev/null 2>&1; then + printf '%s' "$json" | jq -e --arg n "$SERVER_NAME" 'any(.. | objects; .name? == $n)' >/dev/null 2>&1 + else + printf '%s' "$json" | grep -q "\"$SERVER_NAME\"" + fi +} + # --------------------------------------------------------------------------- # Step 2 + 3: authorize this device and supervise the daemon # --------------------------------------------------------------------------- @@ -336,6 +383,14 @@ stdiod_logged_in() { [ -f "$f" ] && grep -q 'client_access_token' "$f" 2>/dev/null } +# Echo the backend_url persisted in config (trailing slash stripped), or nothing. +stdiod_saved_backend() { + local f; f="$(stdiod_config)" + [ -f "$f" ] || return 0 + sed -n 's/^[[:space:]]*backend_url[[:space:]]*=[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}.*/\1/p' \ + "$f" 2>/dev/null | head -n1 | sed 's:/*$::' +} + ensure_stdiod_auth() { step "Edison device authorization (browser)" if [ "$DRY_RUN" -eq 1 ]; then @@ -343,7 +398,12 @@ ensure_stdiod_auth() { return 0 fi if [ "$RELOGIN" -eq 0 ] && stdiod_logged_in; then - ok "already authorized on this device (client credential present in $(stdiod_config))" + local saved; saved="$(stdiod_saved_backend)" + if [ -n "$saved" ] && [ "$saved" != "${EW_BACKEND%/}" ]; then + warn "this device is authorized to ${saved}, not ${EW_BACKEND}; using ${saved} (pass --relogin to switch)" + EW_BACKEND="$saved" + fi + ok "already authorized on this device (client credential in $(stdiod_config))" return 0 fi # `edison-stdiod login` runs the OAuth device flow: it prints a URL to approve @@ -372,7 +432,7 @@ ensure_stdiod_supervised() { # --------------------------------------------------------------------------- # Under device authorization, `server add` submits a request scoped to this # exact device (POST /api/v1/client/mcp-requests). An org admin approves it once -# in the dashboard; it does not run until then. `server list` shows only +# in the dashboard; it does not run until then. `server_registered` lists only # approved servers bound to this device, so we use it as the idempotency check. submit_beeper_server() { step "Submitting the Beeper server" @@ -381,7 +441,7 @@ submit_beeper_server() { --command npx --arg=-y --arg="$MCP_PKG" return 0 fi - if edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + if server_registered; then ok "server '$SERVER_NAME' is already approved and bound to this device" return 0 fi @@ -390,7 +450,7 @@ submit_beeper_server() { local out rc out="$(edison-stdiod server add "$SERVER_NAME" --display-name "Beeper" \ --command npx --arg=-y --arg="$MCP_PKG" 2>&1)" && rc=0 || rc=$? - printf '%s\n' "$out" | grep -viE '^\s*$' >&2 || true + printf '%s\n' "$out" | grep -viE '^[[:space:]]*$' >&2 || true if [ "$rc" -ne 0 ]; then if printf '%s' "$out" | grep -qiE 'already (exists|submitted|pending)|duplicate'; then ok "a request for '$SERVER_NAME' is already pending; approve it in the dashboard" @@ -429,8 +489,8 @@ report_beeper_token() { fi todo "in the Edison dashboard, open server '$SERVER_NAME' and set env BEEPER_ACCESS_TOKEN=" info "the daemon respawns the child with that env once it is saved" - # In --json mode the token is emitted by print_result; here we keep it off - # stdout so it is not captured by accident. + # Keep the token off stdout so it is not captured by accident; print_result + # only reports whether one was found. BEEPER_ACCESS_TOKEN="$tok" } @@ -448,13 +508,13 @@ print_result() { [ -t 1 ] || { b=; g=; d=; r=; } printf '%smcp_url:%s %s%s%s\n' "$b" "$r" "$g" "$mcp_url" "$r" printf '%sserver:%s %s (gateway prefix: %s_*)\n' "$b" "$r" "$SERVER_NAME" "$SERVER_NAME" - printf '%sdevice:%s %s\n' "$b" "$r" "$DEVICE_LABEL" + printf '%sdevice:%s %s (display label)\n' "$b" "$r" "$DEVICE_LABEL" if [ -n "$EW_API_KEY" ]; then printf '\n%s# add to Claude Code (gateway auth uses your Edison API key):%s\n' "$d" "$r" printf 'claude mcp add edison %s -t http -H "Authorization: Bearer %s" -s user\n' "$mcp_url" "$EW_API_KEY" else printf '\n%s# the AI client authenticates to the gateway with your Edison API key or OAuth;%s\n' "$d" "$r" - printf '%s# pass --ew-api-key to print a ready-to-run `claude mcp add` snippet.%s\n' "$d" "$r" + printf '%s# pass --ew-api-key to print a ready-to-run claude-mcp-add snippet.%s\n' "$d" "$r" fi } @@ -483,16 +543,16 @@ cmd_doctor() { if stdiod_logged_in; then ok "device authorized to Edison"; else warn "not authorized (run: $PROG install)"; allgood=0; fi if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod status >/dev/null 2>&1; then ok "stdiod daemon connected"; else warn "stdiod daemon not running (run: $PROG install)"; allgood=0; fi - if command -v edison-stdiod >/dev/null 2>&1 && edison-stdiod server list --json 2>/dev/null | grep -q "\"$SERVER_NAME\""; then + if server_registered; then ok "server '$SERVER_NAME' approved on this device"; else warn "server '$SERVER_NAME' not approved yet (submit + approve in dashboard)"; fi - [ "$allgood" -eq 1 ] && ok "core checks passed" || die "some checks failed (see above)" "$PROG install --install-deps" + if [ "$allgood" -eq 1 ]; then ok "core checks passed"; else die "some checks failed (see above)" "$PROG install --install-deps"; fi } cmd_status() { need_cmd edison-stdiod run edison-stdiod status local base; base="$(beeper_api_base 2>/dev/null || true)" - [ -n "$base" ] && ok "Beeper Desktop API: $base" || warn "Beeper Desktop API not reachable" + if [ -n "$base" ]; then ok "Beeper Desktop API: $base"; else warn "Beeper Desktop API not reachable"; fi } cmd_token() { @@ -594,6 +654,8 @@ main() { ARGS=() parse_flags "$@" || { init_colors; subcmd_help "$cmd"; exit 0; } init_colors + # No subcommand takes positional args; reject stray ones so typos are loud. + [ "${#ARGS[@]}" -gt 0 ] && die "unexpected argument: ${ARGS[0]}" "run '$PROG --help' for usage" case "$cmd" in install) cmd_install;; From 81915e0b758d37c15ef6c5855d9f9e2ab16a2d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 10:57:42 +0000 Subject: [PATCH 19/21] install-beeper: respect an explicit --ew-backend on session mismatch When the device is already authorized to a different backend than requested, only fall back to the saved session when --ew-backend was not given. An explicit --ew-backend that disagrees now stops with switch guidance instead of silently targeting the saved backend. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 2e5c03d..5035de3 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -49,6 +49,7 @@ export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" # Defaults (every one overridable by flag or environment variable) # --------------------------------------------------------------------------- EW_BACKEND="${EW_BACKEND:-https://dashboard.edison.watch}" +EW_BACKEND_SET=0 # 1 once --ew-backend is given on the CLI EW_API_KEY="${EW_API_KEY:-}" # only for the mcp-url client snippet BEEPER_ACCESS_TOKEN="${BEEPER_ACCESS_TOKEN:-}" # skip token discovery if set SERVER_NAME="${SERVER_NAME:-beeper}" # tunnel server name / gateway prefix @@ -147,7 +148,7 @@ needval() { parse_flags() { while [ $# -gt 0 ]; do case "$1" in - --ew-backend) needval $# "$1" "${2:-}"; EW_BACKEND="$2"; shift 2;; + --ew-backend) needval $# "$1" "${2:-}"; EW_BACKEND="$2"; EW_BACKEND_SET=1; shift 2;; --ew-api-key) needval $# "$1" "${2:-}"; EW_API_KEY="$2"; shift 2;; --beeper-token) needval $# "$1" "${2:-}"; BEEPER_ACCESS_TOKEN="$2"; shift 2;; --server-name) needval $# "$1" "${2:-}"; SERVER_NAME="$2"; shift 2;; @@ -400,7 +401,14 @@ ensure_stdiod_auth() { if [ "$RELOGIN" -eq 0 ] && stdiod_logged_in; then local saved; saved="$(stdiod_saved_backend)" if [ -n "$saved" ] && [ "$saved" != "${EW_BACKEND%/}" ]; then - warn "this device is authorized to ${saved}, not ${EW_BACKEND}; using ${saved} (pass --relogin to switch)" + # An explicit --ew-backend that disagrees with the saved session is + # ambiguous, so stop rather than silently target the wrong backend. With + # no explicit flag, prefer the authorized session. + if [ "$EW_BACKEND_SET" -eq 1 ]; then + die "this device is authorized to ${saved}, but --ew-backend asked for ${EW_BACKEND}" \ + "pass --relogin to switch to ${EW_BACKEND}, or drop --ew-backend to keep ${saved}" + fi + warn "using the authorized backend ${saved} (pass --ew-backend --relogin to switch)" EW_BACKEND="$saved" fi ok "already authorized on this device (client credential in $(stdiod_config))" From 39209c7ed86c7aa21e88bad307f675a2a4bba204 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:10:33 +0000 Subject: [PATCH 20/21] install-beeper: add bind-token subcommand for the post-approval env push Real-world testing showed the device-scoped `server add` declares no env, so the dashboard shows no field to set BEEPER_ACCESS_TOKEN. The admin route POST /servers/{name}/env still accepts and verifies it, so re-add that push as an explicit `bind-token` subcommand (needs --ew-api-key admin), to run after the request is approved. Also correct the approval guidance (Servers/Overview, "not verified" pre-token is expected) and point the token step at bind-token. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 61 +++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index 5035de3..d918683 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -468,7 +468,8 @@ submit_beeper_server() { "check 'edison-stdiod status' shows the daemon connected, then re-run: $PROG install" fi ok "submitted '$SERVER_NAME' (npx $MCP_PKG) for approval" - todo "approve '$SERVER_NAME' in the Edison dashboard: ${EW_BACKEND%/} -> Servers / requests" + todo "approve '$SERVER_NAME' as an admin: ${EW_BACKEND%/} -> Servers page (pending requests), or Overview" + info "a 'not verified' badge before the token is set is expected and does not block approval" } # --------------------------------------------------------------------------- @@ -495,8 +496,8 @@ report_beeper_token() { todo "in Beeper Desktop: Settings > Developers > Beeper Desktop API > create/copy a token" tok="" fi - todo "in the Edison dashboard, open server '$SERVER_NAME' and set env BEEPER_ACCESS_TOKEN=" - info "the daemon respawns the child with that env once it is saved" + todo "after approving '$SERVER_NAME', set the token: $PROG bind-token --ew-api-key " + info "the device-scoped add declares no env, so the dashboard shows no field for it; bind-token pushes it via the admin /env route" # Keep the token off stdout so it is not captured by accident; print_result # only reports whether one was found. BEEPER_ACCESS_TOKEN="$tok" @@ -579,6 +580,44 @@ cmd_token() { die "no Beeper access token discovered" "create one in Beeper Desktop > Settings > Developers, or pass --beeper-token " } +# bind-token: push BEEPER_ACCESS_TOKEN onto the (already approved) server via the +# admin env endpoint. The device-scoped `server add` declares no env, so the +# dashboard shows no field for it; this admin route (POST /servers/{name}/env) +# forwards the value to the daemon's env_store and verifies the spawn. Run it +# AFTER the '$SERVER_NAME' request is approved in the dashboard. +cmd_bind_token() { + step "Binding BEEPER_ACCESS_TOKEN to server '$SERVER_NAME'" + [ -n "$EW_API_KEY" ] || die "an admin Edison API key is required to push env" \ + "pass --ew-api-key edison_... (must belong to an org admin on ${EW_BACKEND})" + local tok="$BEEPER_ACCESS_TOKEN" + if [ -z "$tok" ] && [ "$DRY_RUN" -eq 0 ]; then + if ! tok="$(discover_beeper_token)" || [ -z "$tok" ]; then + die "no Beeper token to bind" "pass --beeper-token , or run '$PROG token' to find one" + fi + fi + local url="${EW_BACKEND%/}/api/v1/servers/${SERVER_NAME}/env" + if [ "$DRY_RUN" -eq 1 ]; then + run curl -X POST "$url" '(env: BEEPER_ACCESS_TOKEN=)' + return 0 + fi + local code + code="$(curl -s -o /dev/null -w '%{http_code}' -m 60 --connect-timeout 5 -X POST "$url" \ + -H "Authorization: Bearer ${EW_API_KEY}" -H "Content-Type: application/json" \ + --data "$(printf '{"env":{"BEEPER_ACCESS_TOKEN":"%s"}}' "$tok")" 2>/dev/null || true)" + [ -z "$code" ] && code="000" + case "$code" in + 2*) ok "bound BEEPER_ACCESS_TOKEN; the backend verified the spawn and pushed it to the device [$(mask_token "$tok")]";; + 400) die "the server rejected the env or the spawn did not verify (http 400)" \ + "confirm the '$SERVER_NAME' request is approved and Beeper Desktop is reachable, then re-run: $PROG bind-token";; + 401|403) die "not authorized to push env (http ${code})" \ + "the /env route is admin-only; --ew-api-key must be an org admin on ${EW_BACKEND}";; + 404) die "server '$SERVER_NAME' not found (http 404)" \ + "approve the '$SERVER_NAME' request in the dashboard first, then re-run: $PROG bind-token";; + 000) die "could not reach ${url}" "check the network and that the daemon is connected, then re-run: $PROG bind-token";; + *) die "env push returned http ${code}" "check server '$SERVER_NAME' in the dashboard, then re-run: $PROG bind-token";; + esac +} + cmd_mcp_url() { print_result; } cmd_uninstall() { @@ -608,6 +647,7 @@ Commands: doctor Check prerequisites and current state (read-only) status Show stdiod daemon + Beeper Desktop API status token Discover a reusable Beeper token to paste into the dashboard + bind-token Push BEEPER_ACCESS_TOKEN onto the approved server (needs --ew-api-key admin) mcp-url Print the Edison MCP URL and client snippet uninstall Withdraw the server and remove the supervisor unit @@ -646,6 +686,8 @@ subcmd_help() { install) log "install - wire the Edison side and print remaining human steps. Idempotent; safe to re-run." log " optional: --ew-backend, --no-open, --install-deps, --yes, --dry-run.";; token) log "token - discover a reusable Beeper token to paste into the dashboard.";; + bind-token) log "bind-token - push BEEPER_ACCESS_TOKEN onto the approved server via the admin /env route." + log " needs --ew-api-key . Run after approving the '$SERVER_NAME' request.";; mcp-url) log "mcp-url - print the gateway URL + client snippet. pass --ew-api-key for a ready-to-run snippet. supports --json.";; status) log "status - show stdiod daemon + Beeper Desktop API status.";; doctor) log "doctor - verify prerequisites and current state (read-only).";; @@ -666,12 +708,13 @@ main() { [ "${#ARGS[@]}" -gt 0 ] && die "unexpected argument: ${ARGS[0]}" "run '$PROG --help' for usage" case "$cmd" in - install) cmd_install;; - doctor) cmd_doctor;; - status) cmd_status;; - token) cmd_token;; - mcp-url) cmd_mcp_url;; - uninstall) cmd_uninstall;; + install) cmd_install;; + doctor) cmd_doctor;; + status) cmd_status;; + token) cmd_token;; + bind-token) cmd_bind_token;; + mcp-url) cmd_mcp_url;; + uninstall) cmd_uninstall;; ""|help|-h|--help) usage;; *) die "unknown command: $cmd" "run '$PROG --help' for the command list";; esac From 8e6b639794cb6d88e93b374c3a8458ff03e24bc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:42:26 +0000 Subject: [PATCH 21/21] install-beeper: add `token --reveal`; fix stale token guidance The `token` action line pointed at a dashboard field that does not exist for a device-auth server. Point it at `bind-token` instead, and add --reveal to print the full token to stdout on demand (default stays masked). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011CRjwx1BeWkTiS97Acyjvx --- scripts/install-beeper.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh index d918683..0cdc861 100755 --- a/scripts/install-beeper.sh +++ b/scripts/install-beeper.sh @@ -67,6 +67,7 @@ VERBOSE=0 NO_COLOR_FLAG=0 NO_OPEN=0 # pass through to `edison-stdiod login --no-open` for headless auth RELOGIN=0 # force a fresh `edison-stdiod login` even if already authorized +REVEAL=0 # `token --reveal` prints the full token to stdout (default: masked) PROG="$(basename "$0")" @@ -155,6 +156,7 @@ parse_flags() { --device-label) needval $# "$1" "${2:-}"; DEVICE_LABEL="$2"; shift 2;; --no-open) NO_OPEN=1; shift;; --relogin) RELOGIN=1; shift;; + --reveal) REVEAL=1; shift;; --dry-run) DRY_RUN=1; shift;; -y|--yes) ASSUME_YES=1; shift;; --interactive) INTERACTIVE=1; shift;; @@ -566,18 +568,20 @@ cmd_status() { cmd_token() { step "Discovering a Beeper access token" - if [ -n "$BEEPER_ACCESS_TOKEN" ]; then - ok "a token is already supplied [$(mask_token "$BEEPER_ACCESS_TOKEN")]" - return 0 + local tok="$BEEPER_ACCESS_TOKEN" + if [ -z "$tok" ]; then + if ! tok="$(discover_beeper_token)" || [ -z "$tok" ]; then + warn "no reusable token found (is Beeper Desktop running with MCP enabled?)" + die "no Beeper access token discovered" "create one in Beeper Desktop > Settings > Developers, or pass --beeper-token " + fi fi - local tok - if tok="$(discover_beeper_token)" && [ -n "$tok" ]; then - ok "found a working token [$(mask_token "$tok")]" - todo "set it as BEEPER_ACCESS_TOKEN on server '$SERVER_NAME' in the Edison dashboard" + if [ "$REVEAL" -eq 1 ]; then + ok "token found [$(mask_token "$tok")]; printing the full value to stdout" + printf '%s\n' "$tok" # raw value on stdout so it can be piped/copied return 0 fi - warn "no reusable token found (is Beeper Desktop running with MCP enabled?)" - die "no Beeper access token discovered" "create one in Beeper Desktop > Settings > Developers, or pass --beeper-token " + ok "found a working token [$(mask_token "$tok")]" + info "run '$PROG bind-token --ew-api-key ' to push it (no copy needed); add --reveal to print the full value" } # bind-token: push BEEPER_ACCESS_TOKEN onto the (already approved) server via the