#!/usr/bin/env bash set -euo pipefail # FCM-M2-001 boundary: only a roster-derived .env.generated projection and a # separately parsed data-only .env.local can influence launch. Never source an # environment file and never accept a command string from either file. MODE=launch case "${1:-}" in --stop) MODE=stop AGENT_NAME=${2:-} ;; --interaction) MODE=interaction AGENT_NAME=${2:-} ;; *) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;; esac MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic} fail() { echo "ERROR: $*" >&2 exit 64 } hash_value() { printf '%s' "$1" | sha256sum | awk '{print $1}' } fail_env() { local code="$1" local key="$2" local value="$3" echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2 exit 64 } safe_agent_name() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] } safe_policy_name() { [[ "$1" =~ ^[a-z][a-z0-9-]*$ ]] } safe_path() { [[ "$1" == /* ]] || return 1 [[ "$1" != *".."* ]] || return 1 [[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]] } assert_private_regular_file() { local file="$1" [ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file" local mode mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file" (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file" } assert_managed_directory() { local directory="$1" [ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory" local mode mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" (( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" } assert_private_directory() { local directory="$1" assert_managed_directory "$directory" local mode mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" } [ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required" safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME" safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME" FLEET_DIR="$MOSAIC_HOME/fleet" AGENT_ENV_DIR="$FLEET_DIR/agents" assert_managed_directory "$MOSAIC_HOME" assert_managed_directory "$FLEET_DIR" assert_private_directory "$AGENT_ENV_DIR" GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated" LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local" declare -A GENERATED_VALUES=() declare -A LOCAL_VALUES=() declare -A SEEN_KEYS=() is_sensitive_key() { [[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]] } is_generated_key() { case "$1" in MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;; *) return 1 ;; esac } is_local_key() { case "$1" in MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;; *) return 1 ;; esac } validate_generated_value() { local key="$1" local value="$2" case "$key" in MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;; MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;; MOSAIC_AGENT_RUNTIME) case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac ;; MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;; MOSAIC_AGENT_REASONING) case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac ;; MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;; MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;; MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;; esac } validate_local_value() { local key="$1" local value="$2" if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value" else safe_path "$value" || fail_env unsafe-path "$key" "$value" fi } load_environment_file() { local file="$1" local kind="$2" [ -e "$file" ] || { [ "$kind" = generated ] && fail_env missing-file '(generated)' "$file" return 0 } assert_private_regular_file "$file" SEEN_KEYS=() local line key value while IFS= read -r line || [ -n "$line" ]; do [ -z "$line" ] && continue if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then fail_env malformed-line '(malformed)' "$line" fi key=${BASH_REMATCH[1]} value=${BASH_REMATCH[2]} [ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value" SEEN_KEYS[$key]=1 is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value" if [ "$kind" = generated ]; then is_generated_key "$key" || fail_env unknown-key "$key" "$value" validate_generated_value "$key" "$value" GENERATED_VALUES[$key]=$value else is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value" is_local_key "$key" || fail_env unknown-key "$key" "$value" validate_local_value "$key" "$value" LOCAL_VALUES[$key]=$value fi done < "$file" } load_environment_file "$GENERATED_ENV" generated for required_key in \ MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \ MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" '' done load_environment_file "$LOCAL_ENV" local [ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \ fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]} MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]} MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]} MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]} MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]} MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]} MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]} MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-} MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run} MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15} MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-} CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-} if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 fi _tmux() { if [ -n "$MOSAIC_TMUX_SOCKET" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" "$@" else tmux "$@" fi } assert_owned_tmux_server() { local owner_file="$MOSAIC_HOME/fleet/run/holder-owner" [ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing" local owner_mode owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable" (( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions" local owner owner=$(tr -d '\n' < "$owner_file") [[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed" _tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent" local environment expected environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable" expected=$(printf '%s\n' \ "HOME=$HOME" \ 'PATH=/usr/bin:/bin' \ "PWD=$HOME" \ "MOSAIC_FLEET_OWNER=$owner" \ 'MOSAIC_TMUX_HOLDER=_holder' \ "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort) [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \ fail "tmux server ownership or environment validation failed" } # Validate exact server ownership before querying, cleaning, or creating any # managed session. An unmanaged or contaminated named socket is never repaired. assert_owned_tmux_server if [ "$MODE" = interaction ]; then [ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi" [ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \ fail "operator interaction service requires the pinned model" [ "$MOSAIC_AGENT_REASONING" = high ] || \ fail "operator interaction service requires high reasoning" [ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \ fail "operator interaction service requires the operator-interaction tool policy" fi if [ "$MODE" = stop ]; then _tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true exit 0 fi if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" exit 0 fi # Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under # that home. Derive the pane home from the canonical path when available so an # inherited pane/session HOME cannot become runtime authority. PANE_HOME=$HOME case "$MOSAIC_HOME" in */.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;; esac _build_runtime_bin_prefix() { local candidates=() if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi if command -v npm >/dev/null 2>&1; then local npm_prefix npm_prefix=$(npm config get prefix 2>/dev/null) || true if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi fi candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin") local prefix="" dir for dir in "${candidates[@]}"; do [ -d "$dir" ] || continue case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac done printf '%s' "$prefix" } MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin _ensure_claude_workdir_trusted() { local workdir="$1" local resolved resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" claude_json="${claude_json:-$HOME/.claude.json}" command -v python3 >/dev/null 2>&1 || return 1 MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile cj = os.environ["MOSAIC_CJ"] d = os.environ["MOSAIC_TRUST_DIR"] try: data = json.load(open(cj)) if os.path.exists(cj) else {} if not isinstance(data, dict): data = {} except Exception: sys.exit(2) projects = data.setdefault("projects", {}) entry = projects.get(d) if not isinstance(entry, dict): entry = {} projects[d] = entry entry["hasTrustDialogAccepted"] = True tmp_dir = os.path.dirname(cj) or "." fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.") try: with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) os.replace(tmp, cj) except Exception: try: os.unlink(tmp) except OSError: pass sys.exit(3) PY } if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 fi LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi # The tmux holder owns a named server. Explicitly clear the pane environment # so server/session variables cannot cross the launch boundary; retain only # trusted bootstrap, generated, and approved local data as argv assignments. LAUNCH_ENV=( /usr/bin/env -i "HOME=$PANE_HOME" "PATH=$PANE_PATH" "MOSAIC_HOME=$MOSAIC_HOME" "MOSAIC_AGENT_NAME=$AGENT_NAME" "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS" "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME" "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL" "MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING" "MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY" "MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR" "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" "MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR" ) mkdir -p "$MOSAIC_AGENT_WORKDIR" _tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}" PANE_PID="" for _retry in 1 2 3 4 5; do PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) [ -n "$PANE_PID" ] && break sleep 0.2 done _start_heartbeat_sidecar() { local agent="$1" pane_pid="$2" run_dir="$3" interval="$4" local hb_file="${run_dir}/${agent}.hb" mkdir -p "$run_dir" local sidecar_script sidecar_script=$(printf \ 'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \ "$hb_file" "$pane_pid" "$interval") if command -v setsid >/dev/null 2>&1; then setsid bash -c "$sidecar_script" /dev/null 2>&1 & else bash -c "$sidecar_script" /dev/null 2>&1 & fi disown $! 2>/dev/null || true } if [ -n "$PANE_PID" ]; then _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 else echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2 fi