Skill: jonathan97480/ssh-handoff (version 1.0.1)

--- name: ssh-handoff description: Create and reuse a secure shared terminal handoff when a human must authenticate first and the agent must resume work in the same shell session afterward. Use for SSH handoff, sudo handoff, browser-opened temporary terminal access, or LAN-restricted terminal sharing backed by tmux when direct agent authentication is blocked or undesirable. --- # SSH Handoff Use this skill when a human must complete a sensitive authentication step first, and the agent should continue in the exact same shell session afterward. The core pattern is: 1. keep terminal state in a named `tmux` session 2. let the human attach and authenticate inside that session 3. capture the pane state 4. let the agent continue from the same shell Prefer this skill when the user should not paste credentials into chat and when direct agent authentication is blocked, undesirable, or less safe than a shared-session handoff. ## Choose the simplest mode Prefer Mode A unless browser access is actually needed. ### Mode A — plain tmux handoff Use when the human already has terminal access to the host. Typical flow: 1. create or reuse a named `tmux` session 2. ask the human to attach 3. let the human authenticate 4. capture the pane 5. continue through `tmux` Example: ```bash tmux has-session -t handoff-session 2>/dev/null || tmux new-session -d -s handoff-session tmux attach -t handoff-session ``` ### Mode B — local browser terminal Use when the human wants a browser-based terminal on the same machine that hosts the service. This mode uses `ttyd` directly and fits: - localhost-only access - quick temporary sessions - cases where basic auth is acceptable Bundled launcher: ```bash ./scripts/start-local-web-terminal.sh handoff-session ``` ### Mode C — LAN-restricted browser terminal with one-shot token Use when the human opens the terminal from another trusted machine on the same local network and you want less friction than repeated username/password entry. This mode works like this: 1. a named `tmux` session holds the real shell state 2. `ttyd` runs on localhost as the terminal backend 3. a small local proxy exposes a LAN URL with a one-shot `?token=` 4. the first valid request becomes a browser session through a cookie 5. the agent continues using the same `tmux` session Bundled launcher: ```bash ./scripts/start-url-token-web-terminal.sh handoff-session ``` ## Requirements Check these first: ```bash command -v tmux command -v ttyd command -v node command -v python3 ``` Install on Debian / Ubuntu: ```bash sudo apt update && sudo apt install -y tmux ttyd ``` `node` must also exist for Mode C because the proxy launcher uses the bundled Node script. ## Quick usage ### Create or reuse the session ```bash tmux has-session -t handoff-session 2>/dev/null || tmux new-session -d -s handoff-session ``` ### Launch Mode C with placeholders Use placeholders that match the environment instead of copying real addresses blindly. The `192.0.2.x` addresses below are documentation-only example addresses. ```bash HOST=<server-ip> CLIENT_IP=<trusted-client-ip> PORT=48080 UPSTREAM_PORT=48081 FORBID_REUSE_IF_AUTHENTICATED=1 ./scripts/start-url-token-web-terminal.sh handoff-session ``` The launcher prints: - one-shot URL - expiry time - proxy and backend PIDs - cleanup command - optional UFW helper commands Example with documentation-only IPs: ```bash HOST=192.0.2.10 CLIENT_IP=192.0.2.20 PORT=48080 UPSTREAM_PORT=48081 FORBID_REUSE_IF_AUTHENTICATED=1 ./scripts/start-url-token-web-terminal.sh handoff-session ``` ### Resume after authentication Always inspect the pane before assuming the handoff worked: ```bash tmux capture-pane -t handoff-session -p | tail -80 ``` Look for: - remote hostname or expected prompt - expected working directory - absence of a password prompt - absence of a terminal program that would swallow commands unexpectedly If uncertain, ask one short confirmation question before continuing. ## Launcher variables ### `start-local-web-terminal.sh` Supported variables: - `HOST` — bind address, default `127.0.0.1` - `PORT` — optional explicit port, otherwise a random free port - `TTL_MINUTES` — default `30` - `BIND_SCOPE` — metadata only, usually `local` or `lan` - `CLIENT_IP` — optional, used only to print UFW helper commands in LAN mode ### `start-url-token-web-terminal.sh` Supported variables: - `HOST` — proxy bind address, default `127.0.0.1` - `PORT` — proxy frontend port, default `48080` - `UPSTREAM_PORT` — localhost `ttyd` backend port, default `48081` - `CLIENT_IP` — optional trusted client IP for UFW helper commands and proxy-side IP filtering - `TTL_MINUTES` — default `30` - `BIND_SCOPE` — metadata only, usually `local` or `lan` - `COOKIE_SECURE` — set to `1` when serving through local HTTPS so the session cookie gets the `Secure` flag - `EXPECTED_HOST` — strict allowed `Host` header, default `<HOST>:<PORT>` - `EXPECTED_ORIGIN` — strict allowed websocket `Origin`, default derived from host and cookie mode - `FORBID_REUSE_IF_AUTHENTICATED` — set to `1` to refuse startup if the tmux pane already looks authenticated - `REPLACE_EXISTING` — set to `1` only after the human explicitly approves replacing the current web handoff for the same `SESSION_NAME`; otherwise the launcher prints the existing URL/PIDs/cleanup command and exits without touching the running session - `AUTH_GUARD_REGEX` — optional override for the pane-authentication detection regex If the default ports are already occupied, override them explicitly. When a handoff already exists for the same `SESSION_NAME`, the launcher does not silently start a second one: it reports the existing session details and exits with `READY=0`. Ask the human whether to replace it. Use `REPLACE_EXISTING=1` only after explicit approval. If the requested proxy or upstream port is already occupied, the launcher also exits with `READY=0` and reports a port conflict instead of killing anything automatically. In that case, either get approval to replace the conflicting session or relaunch on another port and update firewall rules if LAN access is involved. ## Use Mode C correctly 1. ensure the `tmux` session exists 2. launch the token-based terminal 3. if needed, allow access only from the trusted client IP 4. send the printed one-shot URL to the human through an appropriate channel 5. let the human authenticate inside the terminal 6. capture the pane and verify state 7. continue through `tmux` 8. if the launcher reports an existing session or a port conflict, ask whether to replace it or use a different port 9. stop the temporary web terminal when done ## Cleanup For Mode B, use: ```bash ./scripts/stop-local-web-terminal.sh <pid> <session-name> ``` For Mode C, prefer the printed cleanup command because it removes both temporary processes and the temporary runtime directory: ```bash TTYD_PID=<ttyd-pid> PROXY_PID=<proxy-pid> RUNTIME_DIR=<runtime-dir> <cleanup-script> ``` If that command is unavailable, killing the printed proxy and backend PIDs is still acceptable. The launcher also installs automatic TTL cleanup for the proxy, `ttyd`, and temporary files. Leave the `tmux` session alive if it may be reused. ## Guardrails - Keep exposure local-only by default. - If LAN exposure is needed, restrict it to one trusted client IP only. - Do not expose the terminal through a public tunnel or reverse proxy. - Do not ask the human to paste passwords, OTP codes, or private keys into chat if the handoff can avoid it. - Use short-lived access material for browser modes. - Prefer one `tmux` session per target or task. - Capture pane state before sending more commands. - Ask before destructive actions. - Enable `FORBID_REUSE_IF_AUTHENTICATED=1` by default for normal use; disable it only when reopening an already-authenticated session is intentional. - Treat the printed one-shot URL as sensitive until expiry. - Expect the proxy to reject mismatched `Host`, websocket `Origin`, or client IP when those checks are configured. - Do not suggest or launch browser-terminal modes casually from external messaging channels such as Telegram, Discord, Slack, or similar remote chat surfaces. - Prefer plain `tmux` handoff instead when the interaction happens over an external channel, unless the human explicitly confirms a trusted local-network setup and accepts the risk. ## References Read these when needed: - `references/examples.md` — generic usage examples - `references/design-notes.md` — security and design envelope - `references/lan-restricted.md` — LAN-only restricted-IP pattern Bundled scripts: - `scripts/start-local-web-terminal.sh` - `scripts/start-url-token-web-terminal.sh` - `scripts/stop-local-web-terminal.sh` - `scripts/url-token-proxy.js`

path: README.md
content: # ssh-handoff

Secure shared terminal handoff for cases where a human must authenticate first and the agent must resume work in the same shell session afterward.

## What it does

This skill is built around a simple pattern:

1. keep terminal state inside a named `tmux` session
2. let the human complete the sensitive login step
3. let the agent continue in that exact same shell session

It supports three practical modes:

- plain `tmux` handoff
- local browser terminal via `ttyd`
- LAN-restricted browser terminal with one-shot URL token

## Quick start

1. prefer plain `tmux` handoff when browser access is not needed
2. use the local browser mode for temporary same-machine access
3. use the LAN token mode only for trusted local-network access
4. verify the shell state with `tmux capture-pane` before continuing
5. if a session already exists, ask whether to replace it before using `REPLACE_EXISTING=1`
6. if the requested port is already busy, either get approval to replace the conflicting session or relaunch on another port and update firewall rules if needed
7. clean up the temporary web terminal when the handoff is done

## Included files

- `SKILL.md` — operational instructions for the agent
- `scripts/` — launchers and proxy code
- `references/` — design notes and usage examples

## Runtime requirements

Required binaries:

- `tmux`
- `ttyd`
- `node`
- `python3`

No external downloads are required at runtime. The skill expects these binaries to already be installed from trusted packages.

## Runtime behavior

At runtime, this skill may:

- create temporary files under `/tmp`
- create a temporary state file
- start background processes (`ttyd`, Node proxy, cleanup watcher)
- bind local or LAN ports for temporary browser-terminal access

These behaviors are expected and are part of the documented handoff workflow.

## Notes on examples

Documentation examples may use `192.0.2.x` addresses. These are placeholder documentation-only IPs from the TEST-NET range and must be replaced with real local addresses.

## Security posture

This skill is best suited for:

- localhost-only use
- trusted LAN use with firewall restrictions
- short-lived human-auth-first terminal handoff

Risk guidance:

- localhost: low to moderate risk
- trusted filtered LAN: moderate risk
- loosely controlled shared LAN: moderate to high risk
- public internet exposure: high risk and not recommended

## Recommended hardening

- keep `ttyd` on localhost whenever possible
- expose the frontend only to a trusted client IP when LAN access is required
- keep TTLs short
- treat one-shot URLs as sensitive until expiry
- ask before replacing an existing live session
- prefer `FORBID_REUSE_IF_AUTHENTICATED=1` for normal use
- do not use public tunnels or public reverse proxies for this workflow
- prefer HTTPS/TLS if this is ever used beyond strictly trusted local conditions

## External-channel caution

Do not casually use browser-terminal modes from external messaging channels such as Telegram, Discord, Slack, or similar remote chat surfaces.

In those contexts, prefer:

- plain `tmux` handoff
- or no browser terminal at all

Only use browser-terminal modes when the human explicitly confirms that the setup is local or on a trusted LAN and understands the risk.

## Registry / review note

This skill should be treated as security-sensitive, not malicious. It opens temporary local or LAN terminal endpoints by design for a documented human-auth-first workflow, but it does not download remote code, does not require unrelated cloud credentials, and is not intended for public internet exposure.

## Safety notes

- keep it local-only by default
- if exposed on LAN, restrict access to one trusted client IP
- do not expose through a public tunnel or reverse proxy
- use short TTLs and clean up after use
- do not replace an existing live session without the human's approval

## Repository scope

This repository contains only the `ssh-handoff` skill and its bundled resources.


path: scripts/start-local-web-terminal.sh
content: #!/usr/bin/env bash
set -euo pipefail

SESSION_NAME="${1:-ssh-handoff}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-}"
TTL_MINUTES="${TTL_MINUTES:-30}"
BIND_SCOPE="${BIND_SCOPE:-local}"

need() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "Missing dependency: $1" >&2
    exit 1
  }
}

need tmux
need ttyd
need python3

if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
  tmux new-session -d -s "$SESSION_NAME"
fi

if [[ -z "$PORT" ]]; then
  PORT="$(HOST_FOR_PY="$HOST" python3 - <<'PY'
import os, socket
host = os.environ['HOST_FOR_PY']
s = socket.socket()
s.bind((host, 0))
print(s.getsockname()[1])
s.close()
PY
)"
fi

[REDACTED_SECRET] - <<'PY'
import secrets
print(secrets.token_urlsafe(24))
PY
)"

EXPIRES_AT="$(TTL_FOR_PY="$TTL_MINUTES" python3 - <<'PY'
import os
from datetime import datetime, timedelta, timezone
minutes = int(os.environ['TTL_FOR_PY'])
print((datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat())
PY
)"

CMD=(ttyd -W -o -i "$HOST" -p "$PORT" -c "$[REDACTED_SECRET]" tmux attach -t "$SESSION_NAME")
nohup "${CMD[@]}" >/tmp/ttyd-"$SESSION_NAME".log 2>&1 &
PID=$!

if [[ "$HOST" == "127.0.0.1" ]]; then
  NOTE="Local only. Writable one-shot terminal. It exits after the client disconnects. Do not expose this terminal via public tunnel or reverse proxy."
else
  NOTE="LAN-exposed on $HOST:$PORT with temporary credentials. Writable one-shot terminal. Keep it on trusted local network only; do not expose it publicly."
fi

CLIENT_IP="${CLIENT_IP:-}"
if [[ -n "$CLIENT_IP" && "$HOST" != "127.0.0.1" ]]; then
  UFW_ALLOW_CMD="sudo ufw allow from $CLIENT_IP to any port $PORT proto tcp"
  UFW_DELETE_CMD="sudo ufw delete allow from $CLIENT_IP to any port $PORT proto tcp"
else
  UFW_ALLOW_CMD=""
  UFW_DELETE_CMD=""
fi

cat <<EOF
READY=1
SESSION_NAME=$SESSION_NAME
PID=$PID
HOST=$HOST
PORT=$PORT
[REDACTED_SECRET]
URL=http://$HOST:$PORT/
USERNAME=$TOKEN
[REDACTED_SECRET]
EXPIRES_AT=$EXPIRES_AT
LOG=/tmp/ttyd-$SESSION_NAME.log
STOP_CMD=kill $PID
BIND_SCOPE=$BIND_SCOPE
CLIENT_IP=$CLIENT_IP
UFW_ALLOW_CMD=$UFW_ALLOW_CMD
UFW_DELETE_CMD=$UFW_DELETE_CMD
NOTE=$NOTE
EOF


path: scripts/start-url-token-web-terminal.sh
content: #!/usr/bin/env bash
set -euo pipefail

SESSION_NAME="${1:-ssh-handoff}"
REQUESTED_HOST="${HOST:-127.0.0.1}"
REQUESTED_PORT="${PORT:-48080}"
REQUESTED_CLIENT_IP="${CLIENT_IP:-}"
TTL_MINUTES="${TTL_MINUTES:-30}"
BIND_SCOPE="${BIND_SCOPE:-local}"
UPSTREAM_HOST="127.0.0.1"
REQUESTED_UPSTREAM_PORT="${UPSTREAM_PORT:-48081}"
COOKIE_SECURE="${COOKIE_SECURE:-}"
REQUESTED_EXPECTED_HOST="${EXPECTED_HOST:-${REQUESTED_HOST}:${REQUESTED_PORT}}"
REQUESTED_EXPECTED_ORIGIN="${EXPECTED_ORIGIN:-}"
FORBID_REUSE_IF_AUTHENTICATED="${FORBID_REUSE_IF_AUTHENTICATED:-0}"
REPLACE_EXISTING="${REPLACE_EXISTING:-0}"
AUTH_GUARD_REGEX="${AUTH_GUARD_REGEX:-(^|[^[:alnum:]_])(Last login:|[@][A-Za-z0-9._-]+:|Welcome to|Linux [A-Za-z0-9._-]+|[#$] $)}"
SESSION_NAME_SAFE="$(printf '%s' "$SESSION_NAME" | tr -c '[:alnum:]._:-' '_')"
STATE_FILE="/tmp/ssh-handoff-${SESSION_NAME_SAFE}.env"
HOST="$REQUESTED_HOST"
PORT="$REQUESTED_PORT"
CLIENT_IP="$REQUESTED_CLIENT_IP"
UPSTREAM_PORT="$REQUESTED_UPSTREAM_PORT"
EXPECTED_HOST="$REQUESTED_EXPECTED_HOST"
EXPECTED_ORIGIN="$REQUESTED_EXPECTED_ORIGIN"

need() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "Missing dependency: $1" >&2
    exit 1
  }
}

bool_true() {
  [[ "$1" == "1" || "$1" =~ ^([Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn])$ ]]
}

cleanup_existing_runtime() {
  local state_file="$1"
  if [[ ! -f "$state_file" ]]; then
    return 0
  fi

  # shellcheck disable=SC1090
  source "$state_file"
  if [[ -n "${CLEANUP_CMD:-}" ]]; then
    bash -lc "$CLEANUP_CMD" >/dev/null 2>&1 || true
  else
    if [[ -n "${PROXY_PID:-}" ]] && kill -0 "${PROXY_PID}" 2>/dev/null; then
      kill "${PROXY_PID}" 2>/dev/null || true
    fi
    if [[ -n "${TTYD_PID:-}" ]] && kill -0 "${TTYD_PID}" 2>/dev/null; then
      kill "${TTYD_PID}" 2>/dev/null || true
    fi
    if [[ -n "${RUNTIME_DIR:-}" ]] && [[ -d "${RUNTIME_DIR}" ]]; then
      rm -rf "${RUNTIME_DIR}" || true
    fi
  fi
  rm -f "$state_file"
}

existing_runtime_alive() {
  local state_file="$1"
  if [[ ! -f "$state_file" ]]; then
    return 1
  fi

  # shellcheck disable=SC1090
  source "$state_file"
  if [[ -n "${PROXY_PID:-}" ]] && kill -0 "${PROXY_PID}" 2>/dev/null; then
    return 0
  fi
  if [[ -n "${TTYD_PID:-}" ]] && kill -0 "${TTYD_PID}" 2>/dev/null; then
    return 0
  fi
  return 1
}

pane_snapshot_tail() {
  tmux capture-pane -t "$SESSION_NAME" -p -S -80 2>/dev/null | tail -20 || true
}

write_state_value() {
  local key="$1"
  local value="${2-}"
  printf '%s=%q\n' "$key" "$value"
}

print_existing_runtime_details() {
  local message="$1"
  local pane_tail
  pane_tail="$(pane_snapshot_tail)"
  cat <<EOF
READY=0
SESSION_NAME=$SESSION_NAME
EXISTING_SESSION=1
MESSAGE=$message
EXISTING_HOST=${EXISTING_HOST:-}
EXISTING_PORT=${EXISTING_PORT:-}
EXISTING_URL=${EXISTING_URL:-}
EXISTING_EXPIRES_AT=${EXISTING_EXPIRES_AT:-}
EXISTING_PROXY_PID=${EXISTING_PROXY_PID:-}
EXISTING_TTYD_PID=${EXISTING_TTYD_PID:-}
EXISTING_RUNTIME_DIR=${EXISTING_RUNTIME_DIR:-}
EXISTING_STATE_FILE=$STATE_FILE
EXISTING_CLEANUP_CMD=${EXISTING_CLEANUP_CMD:-}
EXISTING_PANE_TAIL<<'__SSH_HANDOFF_PANE__'
$pane_tail
__SSH_HANDOFF_PANE__
EOF
}

need tmux
need ttyd
need python3
need node

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if existing_runtime_alive "$STATE_FILE"; then
  EXISTING_HOST=''
  EXISTING_PORT=''
  EXISTING_URL=''
  EXISTING_EXPIRES_AT=''
  EXISTING_PROXY_PID=''
  EXISTING_TTYD_PID=''
  EXISTING_RUNTIME_DIR=''
  EXISTING_CLEANUP_CMD=''
  # shellcheck disable=SC1090
  source "$STATE_FILE"
  EXISTING_HOST="${HOST:-}"
  EXISTING_PORT="${PORT:-}"
  EXISTING_URL="${URL:-}"
  EXISTING_EXPIRES_AT="${EXPIRES_AT:-}"
  EXISTING_PROXY_PID="${PROXY_PID:-}"
  EXISTING_TTYD_PID="${TTYD_PID:-}"
  EXISTING_RUNTIME_DIR="${RUNTIME_DIR:-}"
  EXISTING_CLEANUP_CMD="${CLEANUP_CMD:-}"

  HOST="$REQUESTED_HOST"
  PORT="$REQUESTED_PORT"
  CLIENT_IP="$REQUESTED_CLIENT_IP"
  UPSTREAM_PORT="$REQUESTED_UPSTREAM_PORT"
  EXPECTED_HOST="$REQUESTED_EXPECTED_HOST"
  EXPECTED_ORIGIN="$REQUESTED_EXPECTED_ORIGIN"

  if ! bool_true "$REPLACE_EXISTING"; then
    print_existing_runtime_details "An active web handoff already exists for this tmux session. Ask the user whether to replace it. If they do not want that, relaunch on another port and update firewall rules if LAN access is needed."
    exit 0
  fi
  cleanup_existing_runtime "$STATE_FILE"
  HOST="$REQUESTED_HOST"
  PORT="$REQUESTED_PORT"
  CLIENT_IP="$REQUESTED_CLIENT_IP"
  UPSTREAM_PORT="$REQUESTED_UPSTREAM_PORT"
  EXPECTED_HOST="$REQUESTED_EXPECTED_HOST"
  EXPECTED_ORIGIN="$REQUESTED_EXPECTED_ORIGIN"
  sleep 0.2
else
  rm -f "$STATE_FILE"
fi

if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
  tmux new-session -d -s "$SESSION_NAME"
fi

if bool_true "$FORBID_REUSE_IF_AUTHENTICATED"; then
  PANE_SNAPSHOT="$(tmux capture-pane -t "$SESSION_NAME" -p -S -200 2>/dev/null || true)"
  if printf '%s\n' "$PANE_SNAPSHOT" | grep -Eiq "$AUTH_GUARD_REGEX"; then
    echo "Refusing to launch: tmux session appears already authenticated. Set FORBID_REUSE_IF_AUTHENTICATED=0 to override." >&2
    exit 1
  fi
fi

if ss -ltn | awk '{print $4}' | grep -qE "(^|:)$UPSTREAM_PORT$"; then
  cat <<EOF
READY=0
SESSION_NAME=$SESSION_NAME
PORT_CONFLICT=1
CONFLICT_KIND=upstream
CONFLICT_PORT=$UPSTREAM_PORT
MESSAGE=The requested upstream port is already in use. Ask the user whether to free/replace the existing session. If they prefer not to, choose another UPSTREAM_PORT and relaunch. If LAN access is involved, remind them to update firewall rules when needed.
EOF
  exit 0
fi

if ss -ltn | awk '{print $4}' | grep -qE "(^|:)$PORT$"; then
  cat <<EOF
READY=0
SESSION_NAME=$SESSION_NAME
PORT_CONFLICT=1
CONFLICT_KIND=proxy
CONFLICT_PORT=$PORT
MESSAGE=The requested frontend port is already in use. Ask the user whether to free/replace the existing session. If they prefer not to, choose another PORT and relaunch. If LAN access is involved, remind them to update firewall rules when needed.
EOF
  exit 0
fi

ACCESS_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(24))
PY
)"

SESSION_SECRET="$(python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(24))
PY
)"

EXPIRES_AT="$(TTL_FOR_PY="$TTL_MINUTES" python3 - <<'PY'
import os
from datetime import datetime, timedelta, timezone
minutes = int(os.environ['TTL_FOR_PY'])
print((datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat())
PY
)"

RUNTIME_DIR="$(mktemp -d -t ssh-handoff-${SESSION_NAME}-XXXXXX)"
PROXY_INFO_FILE="$RUNTIME_DIR/proxy-info.json"
PROXY_LOG_FILE="$RUNTIME_DIR/proxy.log"
TTYD_LOG_FILE="$RUNTIME_DIR/ttyd.log"
CLEANUP_SCRIPT="$RUNTIME_DIR/cleanup.sh"
METADATA_FILE="$RUNTIME_DIR/meta.env"
: > "$PROXY_INFO_FILE"

if [[ -z "$EXPECTED_ORIGIN" ]]; then
  if [[ -n "$COOKIE_SECURE" && "$COOKIE_SECURE" != "0" ]]; then
    EXPECTED_ORIGIN="https://$EXPECTED_HOST"
  else
    EXPECTED_ORIGIN="http://$EXPECTED_HOST"
  fi
fi

cat > "$CLEANUP_SCRIPT" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
TTYD_PID="${TTYD_PID:-}"
PROXY_PID="${PROXY_PID:-}"
RUNTIME_DIR="${RUNTIME_DIR:-}"
STATE_FILE="${STATE_FILE:-}"
if [[ -n "$PROXY_PID" ]] && kill -0 "$PROXY_PID" 2>/dev/null; then
  kill "$PROXY_PID" 2>/dev/null || true
fi
if [[ -n "$TTYD_PID" ]] && kill -0 "$TTYD_PID" 2>/dev/null; then
  kill "$TTYD_PID" 2>/dev/null || true
fi
sleep 0.2
if [[ -n "$PROXY_PID" ]] && kill -0 "$PROXY_PID" 2>/dev/null; then
  kill -9 "$PROXY_PID" 2>/dev/null || true
fi
if [[ -n "$TTYD_PID" ]] && kill -0 "$TTYD_PID" 2>/dev/null; then
  kill -9 "$TTYD_PID" 2>/dev/null || true
fi
if [[ -n "$RUNTIME_DIR" ]] && [[ -d "$RUNTIME_DIR" ]]; then
  rm -rf "$RUNTIME_DIR"
fi
if [[ -n "$STATE_FILE" ]]; then
  rm -f "$STATE_FILE"
fi
EOF
chmod 700 "$CLEANUP_SCRIPT"

nohup ttyd -W -i "$UPSTREAM_HOST" -p "$UPSTREAM_PORT" tmux attach -t "$SESSION_NAME" >"$TTYD_LOG_FILE" 2>&1 &
TTYD_PID=$!

nohup env \
  LISTEN_HOST="$HOST" \
  LISTEN_PORT="$PORT" \
  UPSTREAM_HOST="$UPSTREAM_HOST" \
  UPSTREAM_PORT="$UPSTREAM_PORT" \
  ACCESS_TOKEN="$ACCESS_TOKEN" \
  SESSION_SECRET="$SESSION_SECRET" \
  TTL_MS="$((TTL_MINUTES * 60 * 1000))" \
  COOKIE_SECURE="$COOKIE_SECURE" \
  EXPECTED_HOST="$EXPECTED_HOST" \
  EXPECTED_ORIGIN="$EXPECTED_ORIGIN" \
  ALLOWED_CLIENT_IP="$CLIENT_IP" \
  node "$SCRIPT_DIR/url-token-proxy.js" >"$PROXY_INFO_FILE" 2>"$PROXY_LOG_FILE" &
PROXY_PID=$!

{
  write_state_value STATE_FILE "$STATE_FILE"
  write_state_value RUNTIME_DIR "$RUNTIME_DIR"
  write_state_value TTYD_PID "$TTYD_PID"
  write_state_value PROXY_PID "$PROXY_PID"
  write_state_value SESSION_NAME "$SESSION_NAME"
  write_state_value HOST "$HOST"
  write_state_value PORT "$PORT"
  write_state_value UPSTREAM_PORT "$UPSTREAM_PORT"
  write_state_value CLIENT_IP "$CLIENT_IP"
  write_state_value EXPECTED_HOST "$EXPECTED_HOST"
  write_state_value EXPECTED_ORIGIN "$EXPECTED_ORIGIN"
  write_state_value EXPIRES_AT "$EXPIRES_AT"
} > "$METADATA_FILE"

for _ in $(seq 1 50); do
  if [[ -s "$PROXY_INFO_FILE" ]]; then
    break
  fi
  sleep 0.1
done

if [[ ! -s "$PROXY_INFO_FILE" ]]; then
  TTYD_PID="$TTYD_PID" PROXY_PID="$PROXY_PID" RUNTIME_DIR="$RUNTIME_DIR" STATE_FILE="$STATE_FILE" "$CLEANUP_SCRIPT" || true
  echo "Proxy failed to start" >&2
  echo "See $PROXY_LOG_FILE" >&2
  exit 1
fi

PROXY_JSON="$(cat "$PROXY_INFO_FILE")"
PROXY_PORT="$(printf '%s' "$PROXY_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["port"])')"

if ! kill -0 "$TTYD_PID" 2>/dev/null; then
  PROXY_PID="$PROXY_PID" RUNTIME_DIR="$RUNTIME_DIR" STATE_FILE="$STATE_FILE" "$CLEANUP_SCRIPT" || true
  echo "ttyd exited unexpectedly" >&2
  exit 1
fi

if ! kill -0 "$PROXY_PID" 2>/dev/null; then
  TTYD_PID="$TTYD_PID" RUNTIME_DIR="$RUNTIME_DIR" STATE_FILE="$STATE_FILE" "$CLEANUP_SCRIPT" || true
  echo "proxy exited unexpectedly" >&2
  echo "See $PROXY_LOG_FILE" >&2
  exit 1
fi

TTL_SECONDS="$((TTL_MINUTES * 60))"
nohup bash -lc "sleep '$TTL_SECONDS'; TTYD_PID='$TTYD_PID' PROXY_PID='$PROXY_PID' RUNTIME_DIR='$RUNTIME_DIR' STATE_FILE='$STATE_FILE' '$CLEANUP_SCRIPT'" >/dev/null 2>&1 &
CLEANUP_WATCHER_PID=$!

URL="http://$HOST:$PROXY_PORT/?[REDACTED_SECRET]"
CLEANUP_CMD="TTYD_PID=$TTYD_PID PROXY_PID=$PROXY_PID RUNTIME_DIR=$RUNTIME_DIR STATE_FILE=$STATE_FILE $CLEANUP_SCRIPT"

write_state_value CLEANUP_WATCHER_PID "$CLEANUP_WATCHER_PID" >> "$METADATA_FILE"
write_state_value URL "$URL" >> "$METADATA_FILE"
write_state_value CLEANUP_CMD "$CLEANUP_CMD" >> "$METADATA_FILE"
cp "$METADATA_FILE" "$STATE_FILE"

if [[ -n "$CLIENT_IP" && "$HOST" != "127.0.0.1" ]]; then
  UFW_ALLOW_CMD="sudo ufw allow from $CLIENT_IP to any port $PROXY_PORT proto tcp"
  UFW_DELETE_CMD="sudo ufw delete allow from $CLIENT_IP to any port $PROXY_PORT proto tcp"
else
  UFW_ALLOW_CMD=""
  UFW_DELETE_CMD=""
fi

if [[ "$HOST" == "127.0.0.1" ]]; then
  NOTE="Local only. URL token is one-shot. Proxy enforces expected host/origin and TTL cleanup."
else
  NOTE="LAN-exposed on $HOST:$PROXY_PORT with one-shot URL token. Restrict firewall to the client IP only and do not expose publicly."
fi

cat <<EOF
READY=1
SESSION_NAME=$SESSION_NAME
HOST=$HOST
PORT=$PROXY_PORT
URL=$URL
EXPIRES_AT=$EXPIRES_AT
TTYD_PID=$TTYD_PID
TTYD_PORT=$UPSTREAM_PORT
PROXY_PID=$PROXY_PID
CLEANUP_WATCHER_PID=$CLEANUP_WATCHER_PID
RUNTIME_DIR=$RUNTIME_DIR
STATE_FILE=$STATE_FILE
CLEANUP_CMD=$CLEANUP_CMD
PROXY_JSON=$PROXY_JSON
BIND_SCOPE=$BIND_SCOPE
CLIENT_IP=$CLIENT_IP
EXPECTED_HOST=$EXPECTED_HOST
EXPECTED_ORIGIN=$EXPECTED_ORIGIN
COOKIE_SECURE=$COOKIE_SECURE
UFW_ALLOW_CMD=$UFW_ALLOW_CMD
UFW_DELETE_CMD=$UFW_DELETE_CMD
NOTE=$NOTE
EOF


path: scripts/stop-local-web-terminal.sh
content: #!/usr/bin/env bash
set -euo pipefail

PID="${1:-}"
SESSION_NAME="${2:-}"

if [[ -n "$PID" ]]; then
  if kill -0 "$PID" 2>/dev/null; then
    kill "$PID"
    echo "Stopped PID $PID"
  else
    echo "PID $PID not running"
  fi
fi

if [[ -n "$SESSION_NAME" ]]; then
  echo "tmux session preserved: $SESSION_NAME"
fi

echo "Cleanup done"


path: scripts/url-token-proxy.js
content: #!/usr/bin/env node
const http = require('http');
const net = require('net');
const { URL } = require('url');
const crypto = require('crypto');

const listenHost = process.env.LISTEN_HOST || '127.0.0.1';
const listenPort = Number(process.env.LISTEN_PORT || '0');
const upstreamHost = process.env.UPSTREAM_HOST || '127.0.0.1';
const upstreamPort = Number(process.env.UPSTREAM_PORT || '7681');
const [REDACTED_SECRET] || '';
const ttlMs = Number(process.env.TTL_MS || String(30 * 60 * 1000));
const writableCookie = process.env.SESSION_COOKIE || 'ssh_handoff_session';
const sessionSecret = process.env.SESSION_SECRET || token;
const secureCookie = /^(1|true|yes|on)$/i.test(process.env.COOKIE_SECURE || '');
const expectedHost = process.env.EXPECTED_HOST || '';
const expectedOrigin = process.env.EXPECTED_ORIGIN || '';
const allowedClientIp = process.env.ALLOWED_CLIENT_IP || '';

if (!token) {
  console.error('Missing ACCESS_TOKEN');
  process.exit(1);
}

let sessionEstablished = false;
let bootstrapUntil = 0;
const expiresAt = Date.now() + ttlMs;
let shuttingDown = false;
const sockets = new Set();

function sha256(value) {
  return crypto.createHash('sha256').update(String(value)).digest('hex');
}

function log(event, details = {}) {
  const payload = {
    ts: new Date().toISOString(),
    event,
    ...details,
  };
  try {
    process.stderr.write(`${JSON.stringify(payload)}\n`);
  } catch {
    process.stderr.write(`{"ts":"${new Date().toISOString()}","event":"log-error"}\n`);
  }
}

function respond(res, statusCode, message) {
  res.statusCode = statusCode;
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  res.end(message);
}

function unauthorized(res) {
  respond(res, 403, 'Forbidden');
}

function expired(res) {
  respond(res, 410, 'Link expired');
}

function badRequest(res, message = 'Bad request') {
  respond(res, 400, message);
}

function parseCookies(req) {
  const raw = req.headers.cookie || '';
  const out = {};
  for (const part of raw.split(';')) {
    const idx = part.indexOf('=');
    if (idx === -1) continue;
    const k = part.slice(0, idx).trim();
    const v = part.slice(idx + 1).trim();
    out[k] = v;
  }
  return out;
}

function normalizeHost(value) {
  return String(value || '').trim().toLowerCase();
}

function normalizeOrigin(value) {
  return String(value || '').trim().replace(/\/$/, '').toLowerCase();
}

function getRemoteAddress(reqOrSocket) {
  return (
    reqOrSocket.socket?.remoteAddress ||
    reqOrSocket.connection?.remoteAddress ||
    reqOrSocket.remoteAddress ||
    ''
  );
}

function ipAllowed(remoteAddress) {
  if (!allowedClientIp) return true;
  if (!remoteAddress) return false;
  const normalized = remoteAddress.replace(/^::ffff:/, '');
  return normalized === allowedClientIp;
}

function hostAllowed(hostHeader) {
  if (!expectedHost) return true;
  return normalizeHost(hostHeader) === normalizeHost(expectedHost);
}

function originAllowed(originHeader) {
  if (!expectedOrigin) return true;
  if (!originHeader) return false;
  return normalizeOrigin(originHeader) === normalizeOrigin(expectedOrigin);
}

function setSessionCookie(res) {
  const parts = [
    `${writableCookie}=${sessionSecret}`,
    'HttpOnly',
    'SameSite=Strict',
    'Path=/',
  ];
  if (secureCookie) parts.push('Secure');
  res.setHeader('Set-Cookie', parts.join('; '));
}

function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;
  log('shutdown', { reason: 'ttl-or-signal' });
  for (const socket of sockets) {
    socket.destroy();
  }
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(0), 1000).unref();
}

function validateRequest(req, res) {
  const remoteAddress = getRemoteAddress(req);
  if (!ipAllowed(remoteAddress)) {
    log('reject-ip', { remoteAddress });
    if (res) unauthorized(res);
    return { ok: false };
  }

  const hostHeader = req.headers.host || '';
  if (!hostAllowed(hostHeader)) {
    log('reject-host', { hostHeader, remoteAddress });
    if (res) badRequest(res, 'Invalid host');
    return { ok: false };
  }

  const upgrade = String(req.headers.upgrade || '').toLowerCase();
  const connection = String(req.headers.connection || '').toLowerCase();
  const isWebSocket = upgrade === 'websocket' || connection.includes('upgrade');
  if (isWebSocket) {
    const originHeader = req.headers.origin || '';
    if (!originAllowed(originHeader)) {
      log('reject-origin', { originHeader, remoteAddress });
      if (res) unauthorized(res);
      return { ok: false };
    }
  }

  return { ok: true, remoteAddress, isWebSocket };
}

function proxyHttp(req, res) {
  const options = {
    host: upstreamHost,
    port: upstreamPort,
    method: req.method,
    path: req.url,
    headers: {
      ...req.headers,
      host: `${upstreamHost}:${upstreamPort}`,
    },
  };

  delete options.headers.origin;

  const upstream = http.request(options, (upstreamRes) => {
    res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
    upstreamRes.pipe(res);
  });

  upstream.on('error', (err) => {
    log('upstream-http-error', { message: err.message });
    respond(res, 502, 'Upstream error');
  });

  req.pipe(upstream);
}

const server = http.createServer((req, res) => {
  if (Date.now() > expiresAt) return expired(res);

  const validation = validateRequest(req, res);
  if (!validation.ok) return;

  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  const cookies = parseCookies(req);
  const hasSession = cookies[writableCookie] === sessionSecret;
  const presentedToken = url.searchParams.get('token');

  if (!hasSession) {
    const tokenValid = presentedToken === token;
    const inBootstrapWindow = sessionEstablished && Date.now() <= bootstrapUntil;
    if (!tokenValid && !inBootstrapWindow) {
      log(sessionEstablished ? 'reject-expired-sessionless' : 'reject-no-auth', {
        remoteAddress: validation.remoteAddress,
      });
      return sessionEstablished ? expired(res) : unauthorized(res);
    }
    if (tokenValid && !sessionEstablished) {
      sessionEstablished = true;
      bootstrapUntil = Date.now() + 10000;
      log('session-established', {
        remoteAddress: validation.remoteAddress,
        tokenHash: sha256(token).slice(0, 12),
      });
    }
    setSessionCookie(res);
    if (url.searchParams.has('token')) {
      url.searchParams.delete('token');
      res.statusCode = 302;
      res.setHeader('Location', url.pathname + (url.searchParams.toString() ? `?${url.searchParams}` : ''));
      res.end();
      return;
    }
  }

  proxyHttp(req, res);
});

server.on('connection', (socket) => {
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
});

server.on('upgrade', (req, socket) => {
  if (Date.now() > expiresAt) {
    socket.destroy();
    return;
  }

  const validation = validateRequest(req);
  if (!validation.ok) {
    socket.destroy();
    return;
  }

  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  const cookies = parseCookies(req);
  const hasSession = cookies[writableCookie] === sessionSecret;
  const presentedToken = url.searchParams.get('token');

  if (!hasSession) {
    const tokenValid = presentedToken === token;
    const inBootstrapWindow = sessionEstablished && Date.now() <= bootstrapUntil;
    if (!tokenValid && !inBootstrapWindow) {
      log(sessionEstablished ? 'reject-ws-expired-sessionless' : 'reject-ws-no-auth', {
        remoteAddress: validation.remoteAddress,
      });
      socket.destroy();
      return;
    }
    if (tokenValid && !sessionEstablished) {
      sessionEstablished = true;
      bootstrapUntil = Date.now() + 10000;
      log('session-established-ws', {
        remoteAddress: validation.remoteAddress,
        tokenHash: sha256(token).slice(0, 12),
      });
    }
  }

  const upstream = net.connect(upstreamPort, upstreamHost, () => {
    const headers = {
      ...req.headers,
      host: `${upstreamHost}:${upstreamPort}`,
    };
    delete headers.origin;
    upstream.write(
      `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
      Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
      '\r\n\r\n'
    );
    socket.pipe(upstream).pipe(socket);
  });

  upstream.on('error', () => {
    log('upstream-ws-error', { remoteAddress: validation.remoteAddress });
    socket.destroy();
  });
});

server.on('error', (err) => {
  console.error(err && err.stack ? err.stack : String(err));
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  console.error(err && err.stack ? err.stack : String(err));
  process.exit(1);
});

process.on('unhandledRejection', (err) => {
  console.error(err && err.stack ? err.stack : String(err));
  process.exit(1);
});

const ttlTimer = setTimeout(shutdown, Math.max(0, ttlMs));
ttlTimer.unref();
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

server.listen(listenPort, listenHost, () => {
  const addr = server.address();
  console.log(JSON.stringify({
    ready: true,
    host: addr.address,
    port: addr.port,
    expiresAt,
    expectedHost: expectedHost || null,
    expectedOrigin: expectedOrigin || null,
    cookieSecure: secureCookie,
    clientIpRestricted: allowedClientIp || null,
  }));
  log('ready', {
    host: addr.address,
    port: addr.port,
    expiresAt: new Date(expiresAt).toISOString(),
    expectedHost: expectedHost || null,
    expectedOrigin: expectedOrigin || null,
    cookieSecure: secureCookie,
    clientIpRestricted: allowedClientIp || null,
  });
});


path: references/design-notes.md
content: # Secure web-terminal design notes

## Goal

Allow a human to authenticate in a browser-opened terminal while keeping the terminal temporary, local or LAN-scoped, and tied to a `tmux` session that preserves shell state for the agent.

## Security envelope

Default requirements:

- bind to `127.0.0.1` by default when remote browser access is not needed
- use short-lived access material such as a temporary token or password
- keep `tmux` as the real terminal state holder
- shut down the temporary web terminal after the task or at TTL expiry
- avoid public tunnels, public reverse proxies, and internet exposure unless the human explicitly asks for that risk tradeoff

For LAN use, tighten exposure with:

- a trusted server bind address only
- a single trusted client IP when possible
- strict `Host` and websocket `Origin` checks when a proxy is used
- cleanup of temporary processes and metadata

## Recommended runtime shape

A launcher should:

1. ensure the `tmux` session exists
2. generate short-lived secrets
3. choose or validate ports
4. launch the backend terminal service
5. launch the access-control layer if needed
6. print the connection details and cleanup information
7. stop automatically at expiry

Pseudo-flow:

```text
ensure tmux session
create short-lived access token
start terminal backend bound to localhost
optionally start LAN-facing proxy with access checks
return connection details
install TTL cleanup
```

## UX notes

Prefer a short operator-facing output:

- one connection method
- one expiry time
- one cleanup command
- one short safety note

The success criterion should be simple: the human authenticates, then the agent confirms the expected prompt or shell state before continuing.

## Failure modes

### Required binaries missing

Explain what is missing and offer either:

- fallback to plain `tmux` handoff, or
- installation with approval

### Browser path unreachable

Do not automatically create a public route. Prefer:

- localhost access on the host machine
- trusted LAN access
- a different handoff mode

### Session state unclear

Capture the pane, inspect the prompt, and avoid sending blind commands. If needed, send one interrupt and capture again before continuing.

### Session appears already authenticated

Prefer refusing to relaunch when reuse would be risky. A configurable guard such as `FORBID_REUSE_IF_AUTHENTICATED=1` is a safer default for repeated use.

## Candidate tools

### ttyd

Pros:

- lightweight
- common on Linux
- easy to wrap around `tmux attach`

Questions to confirm during implementation:

- installed version behavior
- auth capabilities on the target host
- compatibility with local proxying or token gating

### Wetty

Pros:

- browser terminal purpose-built
- Node-friendly ecosystem

Questions to confirm during implementation:

- auth support model
- attach pattern to `tmux`
- install footprint and operational complexity

## Suggested implementation split

- `SKILL.md`: workflow and guardrails
- `scripts/`: deterministic launchers and cleanup helpers
- `references/design-notes.md`: rationale and security envelope

## Non-goals

- embedding the terminal directly into a separate product UI
- public exposure by default
- long-lived shared terminal services with no expiry


path: references/examples.md
content: # SSH handoff examples

## Example 1: SSH login handoff

Goal: let the human perform an SSH login manually, then let the agent continue in the same remote shell.

Suggested flow:

1. create session `handoff-session`
2. ask the human to attach: `tmux attach -t handoff-session`
3. let the human run the SSH login inside `tmux`
4. capture the pane and confirm the remote prompt is visible
5. continue with the next task through `tmux`

## Example 2: Temporary sudo handoff

Goal: let the human enter a sudo password once, then let the agent perform a few administrative checks in the same shell.

Suggested flow:

1. create session `admin-session`
2. let the human attach
3. let the human run `sudo -s` or a single `sudo` command
4. capture the pane and verify the shell state
5. continue carefully, capturing output after important commands

## Example 3: LAN browser handoff

Goal: let the human open a temporary browser terminal from a trusted machine on the same LAN.

Suggested flow:

1. create or reuse session `handoff-session`
2. launch `start-url-token-web-terminal.sh` with placeholder values replaced by the real server IP and trusted client IP
3. send the printed one-shot URL to the human
4. let the human authenticate inside the browser terminal
5. capture the pane and continue through `tmux`
6. run the cleanup command when done

## Example 4: Recovery when session state is unclear

If the pane suggests nested SSH, `vim`, `less`, `sudo`, a password prompt, or a hung command:

1. capture the pane first
2. avoid sending blind commands
3. if needed send `Ctrl-C`
4. capture again
5. only continue when the state is clear


path: references/lan-restricted.md
content: # LAN-restricted browser terminal pattern

Use this pattern when the human opens the temporary terminal from another trusted machine on the same local network.

## Safe flow

1. identify the trusted client IP
2. launch the web terminal on the server LAN IP or another intended internal bind address
3. restrict access to that single client IP and that single port when the firewall supports it
4. send the connection details only to the intended human
5. remove or expire the temporary access path after the task

## Documentation-only example

If the server is `192.0.2.10`, the client is `192.0.2.20`, and the temporary frontend port is `48080`:

Allow:

```bash
sudo ufw allow from 192.0.2.20 to any port 48080 proto tcp
```

Remove later:

```bash
sudo ufw delete allow from 192.0.2.20 to any port 48080 proto tcp
```

Treat these addresses as placeholders. Replace them with the real server IP, trusted client IP, and chosen port.

## Guardrails

- Restrict to one trusted client IP only when possible.
- Do not open the port to `Anywhere` unless the human explicitly accepts that risk.
- Keep the access material temporary.
- Prefer TTL-based cleanup even when a manual delete rule is shown.
- Do not create external tunnels or public reverse proxy exposure as part of this pattern.


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)