feat(fleet): enforce generated environment boundary
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||
# socket_name is honored when set; absent never silently becomes mosaic-fleet
|
||||
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||
# 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.
|
||||
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
case "$MOSAIC_AGENT_REASONING" in
|
||||
''|low|medium|high) ;;
|
||||
*)
|
||||
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||
exit 64
|
||||
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 wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||
_tmux() {
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
@@ -42,140 +210,90 @@ _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
|
||||
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||
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
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
# Precedence:
|
||||
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||
#
|
||||
# Only directories that already exist are included. The prefix is baked into
|
||||
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||
# so every existing candidate must always be included. Dedup within the
|
||||
# constructed prefix avoids listing the same dir twice.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
|
||||
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||
fi
|
||||
|
||||
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
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
candidates+=("$HOME/.npm-global/bin")
|
||||
candidates+=("$HOME/.local/bin")
|
||||
|
||||
local prefix=""
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix="$dir"
|
||||
else
|
||||
case ":${prefix}:" in
|
||||
*":${dir}:"*) ;; # already in our prefix — skip
|
||||
*) prefix="${prefix}:${dir}" ;;
|
||||
esac
|
||||
fi
|
||||
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
|
||||
|
||||
# ── Build the pane command ────────────────────────────────────────────────────
|
||||
# The pane command must:
|
||||
# - Export the augmented PATH so the runtime binary is found.
|
||||
# - exec the agent command so the runtime is the pane's foreground process
|
||||
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||
#
|
||||
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||
# NOW (in this script) because the pane shell inherits the tmux server
|
||||
# environment, not this script's env.
|
||||
#
|
||||
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||
# to avoid nested quoting problems.
|
||||
#
|
||||
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||
# server environment (not this script's, and not the systemd unit's), so the
|
||||
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||
# safe single bash token regardless of the name's characters.
|
||||
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
|
||||
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
|
||||
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
|
||||
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
|
||||
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
|
||||
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
|
||||
# (compose-contract -> readPersonaContractBlock); without this export it sees an
|
||||
# undefined class and silently injects NO persona contract. %q-quote it so it is a
|
||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
|
||||
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
|
||||
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
|
||||
# the first time it opens a directory. A fleet-launched agent has no human to
|
||||
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
|
||||
# reporting "healthy" (the pane process IS alive — it's just blocked).
|
||||
#
|
||||
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
|
||||
# neither does `trustedProjectDirectories` in settings.json (verified empirically
|
||||
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
|
||||
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
|
||||
# answering the prompt writes). So we pre-seed that record here.
|
||||
#
|
||||
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
|
||||
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
|
||||
# Only the claude runtime needs this; codex/pi have no such gate.
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
# The path claude keys on is the resolved cwd it is launched in.
|
||||
local rp
|
||||
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
|
||||
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
|
||||
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}"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
|
||||
local lock="${claude_json}.mosaic-lock"
|
||||
_seed() {
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
|
||||
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"]
|
||||
@@ -184,22 +302,19 @@ try:
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
# Never corrupt an unreadable/partial file — bail without writing.
|
||||
sys.exit(2)
|
||||
projects = data.setdefault("projects", {})
|
||||
entry = projects.get(d)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
projects[d] = entry
|
||||
if entry.get("hasTrustDialogAccepted") is True:
|
||||
sys.exit(0) # already trusted — nothing to do
|
||||
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) # atomic
|
||||
os.replace(tmp, cj)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
@@ -207,56 +322,56 @@ except Exception:
|
||||
pass
|
||||
sys.exit(3)
|
||||
PY
|
||||
}
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
|
||||
else
|
||||
_seed
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MOSAIC_AGENT_RUNTIME" in
|
||||
claude)
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \
|
||||
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
;;
|
||||
esac
|
||||
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 the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||
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" \
|
||||
bash -c "$PANE_SHELL_SNIPPET"
|
||||
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
||||
|
||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||
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)
|
||||
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
|
||||
|
||||
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||
_start_heartbeat_sidecar() {
|
||||
local agent="$1"
|
||||
local pane_pid="$2"
|
||||
local run_dir="$3"
|
||||
local interval="$4"
|
||||
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
||||
local hb_file="${run_dir}/${agent}.hb"
|
||||
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||
# references to any variables from this script's environment.
|
||||
local sidecar_script
|
||||
sidecar_script=$(printf \
|
||||
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||
'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")
|
||||
|
||||
# setsid + disown ensures the sidecar survives this script exiting.
|
||||
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
else
|
||||
@@ -266,7 +381,6 @@ _start_heartbeat_sidecar() {
|
||||
}
|
||||
|
||||
if [ -n "$PANE_PID" ]; then
|
||||
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user