390 lines
14 KiB
Bash
Executable File
390 lines
14 KiB
Bash
Executable File
#!/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 >/dev/null 2>&1 &
|
|
else
|
|
bash -c "$sidecar_script" </dev/null >/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
|