chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${MOSAIC_AGENT_NAME:-}
|
||||
RUNTIME=${MOSAIC_AGENT_RUNTIME:-}
|
||||
MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; }
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; }
|
||||
[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; }
|
||||
[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; }
|
||||
[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; }
|
||||
[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; }
|
||||
|
||||
printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \
|
||||
"$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY"
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# seat-logins.sh — project seat credentials into tea's login config.
|
||||
#
|
||||
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
|
||||
#
|
||||
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
|
||||
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
|
||||
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
|
||||
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
|
||||
# never hand-edit it. Same shape as the config-registry projector, same reason —
|
||||
# a third-party tool that cannot read our store has to be fed.
|
||||
#
|
||||
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
|
||||
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
|
||||
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
|
||||
#
|
||||
# COLLISIONS. tea refuses to store one token under two names ("token already been
|
||||
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
|
||||
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
|
||||
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
|
||||
# token, same access, only the label changes.
|
||||
#
|
||||
# Tokens are never printed, never logged, and never passed on a visible command
|
||||
# line beyond tea's own --token, which is unavoidable with this client. tea's
|
||||
# stderr is echoed on failure with any token-shaped string redacted.
|
||||
#
|
||||
# Usage:
|
||||
# seat-logins.sh # dry run, all seats (default: changes nothing)
|
||||
# seat-logins.sh --apply # mint/refresh all seats
|
||||
# seat-logins.sh --seat <seat> # limit to one seat
|
||||
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
|
||||
set -euo pipefail
|
||||
|
||||
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
|
||||
APPLY=0
|
||||
ADOPT=0
|
||||
ONLY_SEAT=""
|
||||
|
||||
# Instance -> server URL.
|
||||
#
|
||||
# Instances are named here because there is no registry to read them from yet.
|
||||
# Override per-instance without editing this file, which is how a deployment adds
|
||||
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
|
||||
declare -A INSTANCE_URL=(
|
||||
[mosaicstack]="https://git.mosaicstack.dev"
|
||||
[usc]="https://git.uscllc.com"
|
||||
)
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=1; shift ;;
|
||||
--adopt) ADOPT=1; shift ;;
|
||||
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
|
||||
|
||||
url_for() {
|
||||
local inst="$1" ovr
|
||||
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
|
||||
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
|
||||
printf '%s' "${INSTANCE_URL[$inst]:-}"
|
||||
}
|
||||
|
||||
# Redact anything token-shaped before any tea output reaches a log.
|
||||
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
|
||||
|
||||
# token sha256 -> login name, for every login tea already holds. This is what
|
||||
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
|
||||
declare -A TOKEN_OWNER=()
|
||||
if [ -r "$TEA_CONFIG" ]; then
|
||||
while read -r sha lname; do
|
||||
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
|
||||
done < <(python3 - "$TEA_CONFIG" <<'PY'
|
||||
import sys, yaml, hashlib
|
||||
try:
|
||||
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for l in (cfg.get('logins') or []):
|
||||
t = l.get('token')
|
||||
if t:
|
||||
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
|
||||
PY
|
||||
)
|
||||
fi
|
||||
|
||||
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
|
||||
|
||||
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
shopt -s nullglob
|
||||
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
|
||||
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
|
||||
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
|
||||
|
||||
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
|
||||
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
|
||||
name="${inst}-${seat}"
|
||||
url="$(url_for "$inst")"
|
||||
|
||||
if [ -z "$url" ]; then
|
||||
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
|
||||
skipped=$((skipped+1)); continue
|
||||
fi
|
||||
if [ ! -r "$tokfile" ]; then
|
||||
echo " SKIP $name — token not readable"
|
||||
skipped=$((skipped+1)); continue
|
||||
fi
|
||||
|
||||
action="mint"
|
||||
grep -qx "$name" <<<"$existing" && action="refresh"
|
||||
|
||||
# Is this exact token already stored under some OTHER name?
|
||||
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
|
||||
owner="${TOKEN_OWNER[$tsha]:-}"
|
||||
collision=""
|
||||
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
|
||||
|
||||
if [ "$APPLY" -eq 0 ]; then
|
||||
if [ -n "$collision" ]; then
|
||||
if [ "$ADOPT" -eq 1 ]; then
|
||||
echo " PLAN adopt $collision -> $name ($url)"
|
||||
else
|
||||
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||
blocked=$((blocked+1)); continue
|
||||
fi
|
||||
else
|
||||
echo " PLAN $action $name -> $url"
|
||||
fi
|
||||
planned=$((planned+1)); continue
|
||||
fi
|
||||
|
||||
if [ -n "$collision" ]; then
|
||||
if [ "$ADOPT" -eq 0 ]; then
|
||||
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||
blocked=$((blocked+1)); continue
|
||||
fi
|
||||
tea login delete "$collision" >/dev/null 2>&1 || true
|
||||
action="adopt"
|
||||
fi
|
||||
|
||||
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
|
||||
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
|
||||
|
||||
if err="$(tea login add --name "$name" --url "$url" \
|
||||
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
|
||||
case "$action" in
|
||||
mint) minted=$((minted+1)) ;;
|
||||
refresh) refreshed=$((refreshed+1)) ;;
|
||||
adopt) adopted=$((adopted+1)) ;;
|
||||
esac
|
||||
if [ "$action" = adopt ]; then
|
||||
echo " OK adopt $collision -> $name ($url)"
|
||||
else
|
||||
echo " OK $action $name -> $url"
|
||||
fi
|
||||
else
|
||||
# A failure here is real information: the seat's token is dead, or the server
|
||||
# refused it. Do not paper over it; the seat cannot act until it is reminted.
|
||||
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
|
||||
# credential or the client, which cost a diagnosis on 2026-08-21.
|
||||
echo " FAIL $action $name -> $url"
|
||||
echo " tea: $(printf '%s' "$err" | redact | head -1)"
|
||||
failed=$((failed+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$APPLY" -eq 0 ]; then
|
||||
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
|
||||
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
|
||||
echo "no changes made. re-run with --apply."
|
||||
else
|
||||
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
|
||||
fi
|
||||
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/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"
|
||||
|
||||
# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live
|
||||
# under the brain home's fleet/agents when a brain is active; roster, roles
|
||||
# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME.
|
||||
# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts:
|
||||
# 1. MOSAIC_BRAIN_HOME env (explicit, always wins)
|
||||
# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND
|
||||
# ~/.mosaic/fleet/agents exists
|
||||
# 3. MOSAIC_HOME (legacy single-tree)
|
||||
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}"
|
||||
if [ -z "$BRAIN_HOME" ]; then
|
||||
BRAIN_HOME="$MOSAIC_HOME"
|
||||
if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \
|
||||
&& [ -d "$HOME/.mosaic/fleet/agents" ]; then
|
||||
BRAIN_HOME="$HOME/.mosaic"
|
||||
fi
|
||||
fi
|
||||
if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then
|
||||
AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents"
|
||||
fi
|
||||
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_GIT_IDENTITY|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_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$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_GIT_IDENTITY 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]}"
|
||||
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||
|
||||
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_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||
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"
|
||||
}
|
||||
|
||||
# Lease-broker socket preflight (#1292). The gated runtime (`mosaic yolo …` →
|
||||
# launch-runtime.py) registers with the broker or dies ~4 seconds in, with the
|
||||
# diagnostic invisible because tmux destroys the dead pane. This check runs
|
||||
# BEFORE any tmux effect — including the ownership probe below — so a host
|
||||
# without a broker produces a named, surviving refusal instead of a doomed
|
||||
# pane. Exit 75 (EX_TEMPFAIL), distinct from 64 (bad projection) and 69 (host
|
||||
# not ready for other reasons); the agent@ unit is Type=oneshot with no
|
||||
# Restart=, so the failed unit keeps its message instead of looping. Socket
|
||||
# resolution matches launch.ts's defaultLeaseBrokerSocket precedence exactly.
|
||||
# This preflight DETECTS and REFUSES — it never starts the broker (activation
|
||||
# belongs to the fleet control plane; a component that both detects and fixes
|
||||
# cannot be used to measure whether the fix worked).
|
||||
broker_socket_path() {
|
||||
if [ -n "${MOSAIC_LEASE_BROKER_SOCKET:-}" ]; then
|
||||
printf '%s\n' "$MOSAIC_LEASE_BROKER_SOCKET"
|
||||
return 0
|
||||
fi
|
||||
local runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
printf '%s\n' "${runtime_dir}/mosaic-lease/broker.sock"
|
||||
}
|
||||
|
||||
if [ "$MODE" = "launch" ]; then
|
||||
_broker_socket=$(broker_socket_path)
|
||||
if [ ! -S "$_broker_socket" ]; then
|
||||
echo "[fleet] FAIL_LAUNCH broker-absent: lease broker socket ${_broker_socket} missing; runtime launch denied (#1292)." >&2
|
||||
echo "[fleet] remedy: systemctl --user enable --now mosaic-lease-broker.service (or reinstall via: mosaic fleet install)" >&2
|
||||
exit 75
|
||||
fi
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# #1408 hazard: a seat still living on the DEFAULT tmux socket is invisible to the
|
||||
# declared-socket guard below, and launching over it creates a same-name duplicate that
|
||||
# name-addressed comms delivery cannot tell apart. Refuse with a distinct code (76,
|
||||
# after 75 broker-absent) so a cutover wave script can branch on "seat still on legacy
|
||||
# socket" vs "already running" (0) vs "broker absent" (75). Stopping the legacy session
|
||||
# belongs to the cutover procedure, never to this launcher.
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ] && tmux has-session -t "=${AGENT_NAME}" 2>/dev/null; then
|
||||
echo "[fleet] FAIL_LAUNCH seat-on-legacy-socket: session '${AGENT_NAME}' exists on the DEFAULT tmux socket; stop it before launching on '${MOSAIC_TMUX_SOCKET}'." >&2
|
||||
exit 76
|
||||
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
|
||||
# A host with no system Node gets one bootstrapped here by tools/install.sh, which
|
||||
# records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`
|
||||
# by design, so ~/.profile is never read and the directory has to be named here.
|
||||
# The npm probe below cannot cover this: it reports a package prefix
|
||||
# (~/.npm-global), never a Node runtime directory. It sits ahead of the npm probe so
|
||||
# the bootstrapped runtime wins on a host that has both — that is the one the installer
|
||||
# verified — while an explicit MOSAIC_RUNTIME_BIN still outranks it.
|
||||
# Runtime binaries are `#!/usr/bin/env node`, so without this the pane resolves the
|
||||
# binary and then dies on `env: 'node': No such file or directory`.
|
||||
candidates+=("$PANE_HOME/.mosaic/node/current/bin")
|
||||
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
|
||||
|
||||
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
|
||||
# environment. A binary missing from *that* path is a pane that dies in under a
|
||||
# second, inside a session nobody is attached to, with its diagnostic scrolled
|
||||
# into a pane tmux then destroys. Resolve both here, before any effect, where
|
||||
# the failure is still attributable to the thing that caused it.
|
||||
#
|
||||
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
|
||||
# is named exactly like the runtime, so resolving the runtime name is the same
|
||||
# question the pane will ask a moment later — asked while an operator can still
|
||||
# see the answer.
|
||||
_resolve_in_pane_path() {
|
||||
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
|
||||
# here the data is fine and the host is not ready. Callers tell the individual
|
||||
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
|
||||
fail_launch() {
|
||||
local code="$1"
|
||||
shift
|
||||
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||
done
|
||||
|
||||
_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
|
||||
|
||||
# #1408 hazard: prefer the seat's own launch.sh when the brain provides one. It is the
|
||||
# path that binds the auth profile (CLAUDE_SECURESTORAGE_CONFIG_DIR) and seeds the seat
|
||||
# config; `mosaic yolo` relocates CLAUDE_CONFIG_DIR to the seat dir (launch.ts
|
||||
# activeSeatDir/harnessEnv) but performs neither, so a yolo-launched seat points its
|
||||
# config at a directory holding no credentials. The env -i allowlist below still
|
||||
# applies: launch.sh reads its own launch.env.
|
||||
SEAT_LAUNCH="${BRAIN_HOME}/fleet/agents/${AGENT_NAME}/launch.sh"
|
||||
if [ -x "$SEAT_LAUNCH" ]; then
|
||||
LAUNCH_COMMAND=("$SEAT_LAUNCH")
|
||||
echo "[fleet] launch path: seat launch.sh ($SEAT_LAUNCH)"
|
||||
else
|
||||
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
|
||||
echo "[fleet] launch path: mosaic yolo (no executable seat launch.sh)"
|
||||
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_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||
"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
|
||||
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
# #1241. Session present, no pane PID after a second of retries. Whatever this
|
||||
# is, it is not a seat an operator can use, so it is not a success either.
|
||||
fail_launch pane-pid-unresolved \
|
||||
"tmux reports the session but no pane PID after 5 attempts"
|
||||
else
|
||||
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
|
||||
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
|
||||
# command exits, so an absent session one second after new-session means the
|
||||
# runtime died on startup. Reporting it as success is what let `fleet start`
|
||||
# return 0 over three dead panes — the launcher knew, and said the wrong thing
|
||||
# at the wrong severity to the wrong layer.
|
||||
fail_launch pane-did-not-survive \
|
||||
"the pane exited immediately and tmux destroyed the session;" \
|
||||
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||
|
||||
# The shared launcher strictly validates the generated/local data boundary
|
||||
# before it applies this interaction service's pinned profile checks.
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME"
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# A holder may create only the configured named socket. Existing servers are
|
||||
# accepted only when their private install-derived ownership identity, exact
|
||||
# holder session, and complete approved global environment all match.
|
||||
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet}
|
||||
MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder}
|
||||
OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
TMUX_BIN=/usr/bin/tmux
|
||||
|
||||
fail() {
|
||||
echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable"
|
||||
[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing"
|
||||
owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions"
|
||||
MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE")
|
||||
[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed"
|
||||
|
||||
_tmux() {
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
}
|
||||
|
||||
server_running() {
|
||||
_tmux list-sessions >/dev/null 2>&1
|
||||
}
|
||||
|
||||
assert_owned_server() {
|
||||
_tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent"
|
||||
local environment
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable"
|
||||
local expected
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "global environment does not match the owned-server contract"
|
||||
}
|
||||
|
||||
if server_running; then
|
||||
assert_owned_server
|
||||
else
|
||||
cd "$HOME" || fail "trusted home is unavailable"
|
||||
# Start the tmux server itself under the approved environment. The holder pane
|
||||
# receives the same closed environment rather than arbitrary server globals.
|
||||
/usr/bin/env -i \
|
||||
"HOME=$HOME" \
|
||||
PATH=/usr/bin:/bin \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \
|
||||
/usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done'
|
||||
fi
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI-fit regression suite for the #1292 lease-broker socket preflight in
|
||||
# start-agent-session.sh.
|
||||
#
|
||||
# WHY THIS SUITE IS CI-FIT WHERE test-start-agent-session.sh IS NOT (#1017/#1270
|
||||
# context): that older suite's precondition is "the host does not have the pi
|
||||
# binary", which a CI image that ships pi violates — its guard correctly
|
||||
# refuses to report a pass there, so it is excluded from the chain. THIS suite
|
||||
# controls its own preconditions instead of inheriting them from the host: a
|
||||
# fake tmux on PATH, a fake mosaic on PATH, a real unix socket created in a
|
||||
# tmpdir, a hermetic env (env -i, fake HOME, GIT_CONFIG_GLOBAL severed). It
|
||||
# never depends on what the host has installed, so a green here means the same
|
||||
# thing on every host. Anyone adding cases: keep that property — no case may
|
||||
# depend on host state.
|
||||
#
|
||||
# The failure this suite is written down to catch (#1292): a seat launched on a
|
||||
# host with no lease broker dies ~4 seconds in at registration, with the
|
||||
# diagnostic invisible because tmux destroys the dead pane. The preflight runs
|
||||
# BEFORE any tmux effect and refuses with a NAMED code (exit 75, EX_TEMPFAIL)
|
||||
# so the message survives. The agent@ unit is Type=oneshot with no Restart=,
|
||||
# so a failed unit keeps its output instead of looping.
|
||||
#
|
||||
# Cases:
|
||||
# 1. absent socket -> exit 75, message names broker-absent + socket path +
|
||||
# remedy, and NO tmux session was ever created (the doomed-pane half).
|
||||
# 2. present socket (real unix socket in tmpdir) -> proceeds PAST the
|
||||
# preflight (the suite then stops at the next precondition, proving the
|
||||
# preflight was not the refusal).
|
||||
# 3. explicit MOSAIC_LEASE_BROKER_SOCKET wins over XDG_RUNTIME_DIR default.
|
||||
# 4. --stop mode does NOT require the broker (teardown must not be fenced on
|
||||
# a component whose absence is exactly what teardown may follow).
|
||||
#
|
||||
# Sabotage control, run by the developer (not in-suite): remove the preflight
|
||||
# block from start-agent-session.sh, re-run — case 1 fails (a tmux session is
|
||||
# created / exit is not 75), cases 2-4 still pass; restore byte-identically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-broker-preflight}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
ENV_DIR="$WORK_DIR/env"
|
||||
SOCK_DIR="$WORK_DIR/sockets"
|
||||
LOG_FILE="$WORK_DIR/tmux-calls.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
# The script asserts a managed directory tree under MOSAIC_HOME: mosaic/,
|
||||
# mosaic/fleet/, mosaic/fleet/agents/ — private (0700/0750-style) modes, no
|
||||
# symlinks — plus a per-agent env projection. Build the full tree the launcher
|
||||
# expects so the suite reaches the BROKER preflight rather than dying at
|
||||
# environment validation.
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
|
||||
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
|
||||
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated" <<'ENVEOF'
|
||||
MOSAIC_AGENT_NAME=preflight-test
|
||||
MOSAIC_GIT_IDENTITY=preflight-test
|
||||
MOSAIC_AGENT_CLASS=worker
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=/tmp
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
ENVEOF
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated"
|
||||
|
||||
# ─── Fake tmux: records every invocation; new-session marks the marker. ────
|
||||
: > "$LOG_FILE"
|
||||
cat > "$BIN_DIR/tmux" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
|
||||
if [[ "\$*" == *new-session* ]]; then
|
||||
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tmux"
|
||||
|
||||
# ─── Fake mosaic/pi binaries so the script proceeds past its own lookups. ───
|
||||
for bin in mosaic pi claude; do
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
|
||||
chmod +x "$BIN_DIR/$bin"
|
||||
done
|
||||
|
||||
# ─── Minimal launch environment the script expects. ────────────────────────
|
||||
# (Enough for the preflight to be reached; later stages will still fail in
|
||||
# case 2 — that is expected and asserted.)
|
||||
run_session_script() {
|
||||
local mode="$1"; shift
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
|
||||
AGENT_NAME=preflight-test \
|
||||
"$@" \
|
||||
bash "$SCRIPT_DIR/start-agent-session.sh" $mode preflight-test
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
assert() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
assert_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle' in: $haystack" >&2; fail=1; }
|
||||
}
|
||||
assert_not_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
echo "FAIL: $desc — must not contain '$needle'" >&2
|
||||
fail=1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── 1. Absent socket → named refusal, NO tmux session. ────────────────────
|
||||
: > "$LOG_FILE"
|
||||
stderr_file="$WORK_DIR/stderr-1.tmp"
|
||||
set +e
|
||||
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent.sock" 2>"$stderr_file")
|
||||
rc=$?
|
||||
set -e
|
||||
assert "absent socket exit code" "75" "$rc"
|
||||
err=$(cat "$stderr_file")
|
||||
assert_contains "absent socket names the failure" "$err" "FAIL_LAUNCH broker-absent"
|
||||
assert_contains "absent socket names the socket path" "$err" "$SOCK_DIR/absent.sock"
|
||||
assert_contains "absent socket names a remedy" "$err" "mosaic fleet install"
|
||||
log1=$(cat "$LOG_FILE")
|
||||
assert_not_contains "absent socket must not create a tmux session" "$log1" "TMUX-NEW-SESSION-INVOKED"
|
||||
|
||||
# ─── 2. Present socket → passes the preflight. ─────────────────────────────
|
||||
# Expected: ownership/env checks AFTER the preflight may refuse (fixture is
|
||||
# minimal by design); the assertion is only that the refusal is NOT
|
||||
# broker-absent and the exit is NOT 75.
|
||||
# Create a REAL unix socket: a detached python holder binds it and stays alive
|
||||
# for the duration (bash cannot create sockets; a foreground python would
|
||||
# close the socket on exit and -S on a closed-but-unlinked path fails). Written
|
||||
# as a script file + setsid nohup so no job-control/heredoc interaction with
|
||||
# set -e can silently kill the suite.
|
||||
# AF_UNIX binds cap at 108 path bytes; the suite's workdir exceeds that, so
|
||||
# the live socket lives at a SHORT path under /tmp (unique per run, cleaned
|
||||
# with the suite). The preflight takes its socket path explicitly, so this
|
||||
# stays fully controlled.
|
||||
# A real unix socket at a SHORT absolute path (AF_UNIX limit is 108 bytes,
|
||||
# so the repo-deep SOCK_DIR cannot host it). The name is composed, not
|
||||
# `mktemp -u`: the CI image's mktemp dialect rejects that invocation
|
||||
# (pipeline 2562: "mktemp: : Invalid argument"), and no pre-existing file is
|
||||
# wanted anyway — the holder binds it fresh.
|
||||
LIVE_SOCK="/tmp/mosaic-preflight-$RANDOM-$$.sock"
|
||||
trap 'rm -f "$LIVE_SOCK"' EXIT
|
||||
rm -f "$SOCK_DIR/live.sock" "$LIVE_SOCK"
|
||||
cat > "$SOCK_DIR/holder.py" <<'PY'
|
||||
import socket, sys, time
|
||||
path = sys.argv[1]
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.bind(path)
|
||||
s.listen(1)
|
||||
time.sleep(120)
|
||||
PY
|
||||
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
|
||||
HOLDER_PID=$!
|
||||
# Wait for the socket object to exist (bind is near-instant, but do not race it).
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$LIVE_SOCK" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -S "$LIVE_SOCK" ]; then
|
||||
echo "FAIL: could not create live socket fixture (holder pid $HOLDER_PID)" >&2
|
||||
ps -p "$HOLDER_PID" -o pid,stat,cmd --no-headers >&2 || echo "(holder exited)" >&2
|
||||
cat "$SOCK_DIR/holder.err" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
: > "$LOG_FILE"
|
||||
set +e
|
||||
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" 2>"$WORK_DIR/stderr-2.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
# The preflight PASSED if the failure (whatever later stage refused) is NOT
|
||||
# the broker refusal, and tmux was reached or a later precondition named
|
||||
# something else.
|
||||
err2=$(cat "$WORK_DIR/stderr-2.tmp")
|
||||
assert_not_contains "live socket must not refuse broker-absent" "$err2" "broker-absent"
|
||||
if [[ "$rc" == "75" ]]; then
|
||||
echo "FAIL: live socket — preflight still refused (exit 75) with a live socket" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ─── 3. Explicit socket env wins over XDG default. ─────────────────────────
|
||||
set +e
|
||||
out=$(run_session_script "" XDG_RUNTIME_DIR="$SOCK_DIR/no-runtime-here" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent2.sock" 2>"$WORK_DIR/stderr-3.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
assert "explicit env wins (exit 75)" "75" "$rc"
|
||||
assert_contains "explicit env path named" "$(cat "$WORK_DIR/stderr-3.tmp")" "$SOCK_DIR/absent2.sock"
|
||||
|
||||
# ─── 4. --stop is not fenced on the broker. ────────────────────────────────
|
||||
: > "$LOG_FILE"
|
||||
set +e
|
||||
out=$(run_session_script "--stop" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent3.sock" 2>"$WORK_DIR/stderr-4.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
err4=$(cat "$WORK_DIR/stderr-4.tmp")
|
||||
assert_not_contains "--stop must not refuse broker-absent" "$err4" "broker-absent"
|
||||
if [[ "$rc" == "75" ]]; then
|
||||
echo "FAIL: --stop — exit 75 means teardown was fenced on the broker" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
kill "$HOLDER_PID" 2>/dev/null || true
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "start-agent-session lease-broker preflight regression passed"
|
||||
fi
|
||||
exit "$fail"
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI-fit regression suite for the #1408 legacy-socket guard in
|
||||
# start-agent-session.sh.
|
||||
#
|
||||
# Same hermeticity contract as test-agent-session-broker-preflight.sh: a fake
|
||||
# tmux on PATH that scripts its own answers, a real unix socket in a tmpdir so
|
||||
# the broker preflight passes, env -i with a fake HOME. No case depends on host
|
||||
# state.
|
||||
#
|
||||
# The failure this suite is written down to catch: during a socket cutover a
|
||||
# seat's session still lives on the DEFAULT tmux socket while the launcher
|
||||
# targets the named one. The declared-socket has-session check cannot see the
|
||||
# legacy session (measured 2026-08-24: rc=1, script proceeds), so launch
|
||||
# creates a same-name duplicate — and comms delivery, which addresses sessions
|
||||
# by NAME, cannot tell the two apart. The guard refuses with its own code
|
||||
# (exit 76, after 75 broker-absent) BEFORE any tmux mutation.
|
||||
#
|
||||
# Cases:
|
||||
# 1. legacy session present -> exit 76, message names seat-on-legacy-socket
|
||||
# + both sockets' roles, and NO tmux session was created.
|
||||
# 2. legacy session absent -> proceeds PAST the guard (the run then stops at
|
||||
# a later precondition; asserted: exit != 76, stderr lacks the guard's
|
||||
# code, proving the guard was not the refusal).
|
||||
# 3. MOSAIC_TMUX_SOCKET empty (single-socket host) -> guard is inert: the
|
||||
# default-socket probe must not fire at all.
|
||||
#
|
||||
# Sabotage control, run by the developer (not in-suite): remove the guard
|
||||
# block, re-run — case 1 fails (exit is not 76), cases 2-3 still pass;
|
||||
# restore byte-identically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-legacy-socket-guard}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
SOCK_DIR="$WORK_DIR/sockets"
|
||||
LOG_FILE="$WORK_DIR/tmux-calls.log"
|
||||
LEGACY_FLAG="$WORK_DIR/legacy-session-present"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
|
||||
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
|
||||
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" <<'ENVEOF'
|
||||
MOSAIC_AGENT_NAME=lsguard-test
|
||||
MOSAIC_GIT_IDENTITY=lsguard-test
|
||||
MOSAIC_AGENT_CLASS=worker
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=/tmp
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
ENVEOF
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated"
|
||||
|
||||
# A projection with NO named socket, for case 3. Same file minus the socket line.
|
||||
sed '/^MOSAIC_TMUX_SOCKET=/d; s/lsguard-test/lsguard-nosock/' \
|
||||
"$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" \
|
||||
> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
echo 'MOSAIC_TMUX_SOCKET=' >> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
|
||||
# Ownership identity the launcher validates before anything touches tmux:
|
||||
# a 0600 uuid file plus a tmux global environment that matches it exactly.
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
OWNER_UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
printf '%s' "$OWNER_UUID" > "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
|
||||
# The exact env block assert_owned_tmux_server expects; the socket value differs
|
||||
# per case, so cases rewrite it via write_tmux_env before each run.
|
||||
write_tmux_env() {
|
||||
printf '%s\n' \
|
||||
"HOME=$FAKE_HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$FAKE_HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$OWNER_UUID" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$1" > "$WORK_DIR/tmux-env"
|
||||
}
|
||||
|
||||
# ─── Fake tmux ──────────────────────────────────────────────────────────────
|
||||
# Scripted answers: a DEFAULT-socket has-session (argv carries no -L) answers
|
||||
# by the flag file; every named-socket call succeeds (holder present, no
|
||||
# existing session is fine for these cases since refusal happens first).
|
||||
cat > "$BIN_DIR/tmux" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
|
||||
if [[ "\$*" == *new-session* ]]; then
|
||||
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
|
||||
fi
|
||||
if [[ "\$*" == *show-environment* ]]; then
|
||||
cat "$WORK_DIR/tmux-env"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\$*" == *has-session* ]]; then
|
||||
# holder session always present; the seat's DEFAULT-socket presence is the
|
||||
# flag file; the seat is never already-running on the NAMED socket.
|
||||
[[ "\$*" == *_holder* ]] && exit 0
|
||||
if [[ "\$1" == "-L" ]]; then exit 1; fi
|
||||
[[ -e "$LEGACY_FLAG" ]] && exit 0 || exit 1
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tmux"
|
||||
|
||||
for bin in mosaic pi claude; do
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
|
||||
chmod +x "$BIN_DIR/$bin"
|
||||
done
|
||||
|
||||
# Real socket so the #1292 broker preflight passes and the run reaches the guard.
|
||||
# Same idiom as the broker-preflight suite: AF_UNIX binds cap at 108 path bytes,
|
||||
# so the socket lives at a SHORT /tmp path held by a detached python holder (a
|
||||
# foreground bind would close on exit; -S on a closed-but-unlinked path fails).
|
||||
LIVE_SOCK="/tmp/mosaic-lsguard-$RANDOM-$$.sock"
|
||||
trap 'rm -f "$LIVE_SOCK"' EXIT
|
||||
rm -f "$LIVE_SOCK"
|
||||
cat > "$SOCK_DIR/holder.py" <<'PY'
|
||||
import socket, sys, time
|
||||
path = sys.argv[1]
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.bind(path)
|
||||
s.listen(1)
|
||||
time.sleep(120)
|
||||
PY
|
||||
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$LIVE_SOCK" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -S "$LIVE_SOCK" ] || { echo "FAIL: could not create live socket" >&2; exit 1; }
|
||||
|
||||
run_session_script() {
|
||||
local agent="$1"; shift
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
|
||||
MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" \
|
||||
"$@" \
|
||||
bash "$SCRIPT_DIR/start-agent-session.sh" "$agent"
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
assert() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
[[ "$expected" == "$actual" ]] || { echo "FAIL: $desc — expected '$expected', got '$actual'" >&2; fail=1; }
|
||||
}
|
||||
assert_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle'" >&2; fail=1; }
|
||||
}
|
||||
assert_not_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
echo "FAIL: $desc — must not contain '$needle'" >&2
|
||||
fail=1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── 1. Legacy session present → exit 76, no tmux mutation. ─────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-1.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
assert "legacy present exit code" "76" "$rc"
|
||||
assert_contains "names the failure" "$err" "FAIL_LAUNCH seat-on-legacy-socket"
|
||||
assert_contains "names the agent" "$err" "lsguard-test"
|
||||
assert_contains "names the target socket" "$err" "mosaic-fleet"
|
||||
assert_not_contains "no session created" "$(cat "$LOG_FILE")" "TMUX-NEW-SESSION-INVOKED"
|
||||
|
||||
# ─── 2. Legacy session absent → guard is not the refusal. ───────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; rm -f "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-2.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: legacy absent must not exit 76" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent from stderr" "$err" "seat-on-legacy-socket"
|
||||
|
||||
# ─── 3. Empty MOSAIC_TMUX_SOCKET → guard inert, no default-socket probe. ────
|
||||
write_tmux_env ""
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG" # even with a legacy session present
|
||||
stderr_file="$WORK_DIR/stderr-3.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-nosock >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: empty socket must never exit 76 (single-socket host)" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent on single-socket host" "$err" "seat-on-legacy-socket"
|
||||
|
||||
rm -f "$LEGACY_FLAG"
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "start-agent-session legacy-socket guard regression FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "start-agent-session legacy-socket guard regression passed"
|
||||
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh"
|
||||
ROOT=$(mktemp -d)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
TMUX_CALLS=$(mktemp)
|
||||
trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pane_command_clears_environment() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
||||
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_pane_argv() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local bytes index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
bytes=$(wc -c < "$calls_file")
|
||||
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
||||
for ((index = 0; index < ${#argv[@]}; index++)); do
|
||||
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
||||
done
|
||||
}
|
||||
|
||||
check_pane_environment_boundary() {
|
||||
local calls_file="$1"
|
||||
if pane_command_clears_environment "$calls_file"; then
|
||||
return 0
|
||||
fi
|
||||
print_pane_argv "$calls_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
contains_literal() {
|
||||
grep -F -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
contains_line() {
|
||||
grep -xF -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
||||
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
||||
# pipeline could report failure after a successful match when an upstream
|
||||
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
||||
# covered without making stream size part of the semantic contract.
|
||||
PORTABILITY_CALLS="$ROOT/portability-calls"
|
||||
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
||||
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
||||
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
||||
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
||||
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
||||
|
||||
assert_pane_boundary_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_records="$2"
|
||||
local diagnostic
|
||||
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
||||
fail "pane boundary accepted invalid $case_name fixture"
|
||||
fi
|
||||
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
||||
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
||||
contains_literal "$diagnostic" '[000]' || \
|
||||
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
||||
}
|
||||
|
||||
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-env 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-i 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected non-adjacent-i 3
|
||||
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected reversed-boundary 2
|
||||
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}"
|
||||
args=("$@")
|
||||
index=0
|
||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||
case "${args[$index]:-}" in
|
||||
has-session)
|
||||
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
|
||||
# other targets that should answer too — without it there is no way to
|
||||
# model "tmux still reports the session" for a non-holder agent, and the
|
||||
# launcher's pane-pid-unresolved branch is unreachable from this harness.
|
||||
#
|
||||
# A listed target answers only AFTER new-session, because the launcher asks
|
||||
# this question twice about the same name: once before launching, where a
|
||||
# yes means "already running, nothing to do, exit 0", and once after, where
|
||||
# a yes means "the session survived". A shim that answered yes to both
|
||||
# would short-circuit at the first and never reach the branch under test —
|
||||
# it would look like coverage and measure the idempotency path instead.
|
||||
for argument in "${args[@]}"; do
|
||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
|
||||
*" $argument "*)
|
||||
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 1
|
||||
;;
|
||||
show-environment)
|
||||
printf '%s\n' \
|
||||
"HOME=${MOSAIC_TEST_HOME:?}" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=${MOSAIC_TEST_HOME:?}" \
|
||||
"MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-test'
|
||||
exit 0
|
||||
;;
|
||||
list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;;
|
||||
new-session)
|
||||
if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then
|
||||
for ((index = 0; index < ${#args[@]}; index++)); do
|
||||
if [ "${args[$index]}" = /usr/bin/env ]; then
|
||||
"${args[@]:$index}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
cat > "$FAKE_BIN/mosaic" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
|
||||
# before spawning (#1241), so it has to exist somewhere the pane would find it —
|
||||
# not merely on the launcher's own PATH.
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
|
||||
chmod +x "$FAKE_BIN/pi"
|
||||
|
||||
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
|
||||
# it would splice whatever the host has installed into the path under test, and
|
||||
# the missing-binary cases below would pass or fail by accident of the machine.
|
||||
cat > "$FAKE_BIN/npm" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
# PANE_PATH always ends in the system path. A host that installs these there can
|
||||
# not measure the missing-binary cases at all, and a green run would mean
|
||||
# nothing — so say so instead of passing.
|
||||
for host_binary in mosaic pi; do
|
||||
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
|
||||
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
|
||||
fi
|
||||
done
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
mkdir -p "$home/work"
|
||||
install_pane_binaries "$home"
|
||||
}
|
||||
|
||||
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
|
||||
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
|
||||
# launcher's PATH. Tests that need a binary *absent* remove it from here.
|
||||
install_pane_binaries() {
|
||||
local pane_home="$1"
|
||||
mkdir -p "$pane_home/.npm-global/bin"
|
||||
local binary
|
||||
for binary in mosaic pi; do
|
||||
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
|
||||
done
|
||||
}
|
||||
|
||||
run_start() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
||||
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
}
|
||||
|
||||
# Valid generated data launches only the fixed runtime argument array. It never
|
||||
# reads an agent-command string or constructs a bash -c pane payload.
|
||||
HOME_VALID="$ROOT/valid"
|
||||
AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
# A live pane PID is part of what "valid launch" means. Until #1241 this case
|
||||
# ran with none, so the suite's one success path was itself a dead pane the
|
||||
# launcher reported as fine.
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||
if contains_literal "$valid_args" 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
fi
|
||||
|
||||
# ── Brain-home split (canon §2) ─────────────────────────────────────────
|
||||
# When MOSAIC_HOME is the default config home under $HOME and the host carries
|
||||
# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config
|
||||
# home still owns fleet/run (holder-owner) and remains a managed boundary.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_BRAIN="$ROOT/brain-home"
|
||||
CONFIG_HOME="$HOME_BRAIN/.config/mosaic"
|
||||
BRAIN="$HOME_BRAIN/.mosaic"
|
||||
mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work"
|
||||
chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \
|
||||
"$BRAIN/fleet/agents" "$HOME_BRAIN/work"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner"
|
||||
chmod 600 "$CONFIG_HOME/fleet/run/holder-owner"
|
||||
cat > "$BRAIN/fleet/agents/coder-brain.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=coder-brain
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
|
||||
install_pane_binaries "$HOME_BRAIN"
|
||||
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
|
||||
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
|
||||
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
|
||||
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
|
||||
|
||||
# Negative control: the SAME default-config-home shape but without
|
||||
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_NOBRAIN="$ROOT/brainless-home"
|
||||
CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic"
|
||||
write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy"
|
||||
install_pane_binaries "$HOME_NOBRAIN"
|
||||
HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy
|
||||
legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed"
|
||||
|
||||
# The pane must start through an absolute clean-environment boundary. Its
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
|
||||
# Git identity is generated authority, not an optional or independently mutable
|
||||
# local value. Each invalid form must fail before fake tmux receives a call.
|
||||
assert_git_identity_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_code="$2"
|
||||
local home="$ROOT/git-identity-$case_name"
|
||||
local agent="coder-git-identity-$case_name"
|
||||
local generated="$home/fleet/agents/$agent.env.generated"
|
||||
write_generated "$home" "$agent"
|
||||
|
||||
case "$case_name" in
|
||||
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
||||
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
||||
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
||||
local-shadow)
|
||||
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
;;
|
||||
*) fail "unknown Git identity rejection case: $case_name" ;;
|
||||
esac
|
||||
chmod 600 "$generated"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "Git identity case $case_name was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
||||
contains_literal "$output" "code=$expected_code" || \
|
||||
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
||||
}
|
||||
|
||||
assert_git_identity_rejected missing missing-key
|
||||
assert_git_identity_rejected unsafe unsafe-git-identity
|
||||
assert_git_identity_rejected mismatch git-identity-mismatch
|
||||
assert_git_identity_rejected local-shadow generated-key-shadow
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
# Validation must happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_UNSAFE_PARENT="$ROOT/unsafe-parent"
|
||||
write_generated "$HOME_UNSAFE_PARENT" "coder-parent"
|
||||
chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent"
|
||||
mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents"
|
||||
ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
# workdir creation, or tmux effects. The malformed local input proves parsing
|
||||
# was not reached when the ancestor rejection is reported.
|
||||
assert_managed_ancestor_rejected() {
|
||||
local ancestor="$1"
|
||||
local hazard="$2"
|
||||
local home="$ROOT/managed-${ancestor//\//-}-${hazard}"
|
||||
local agent="coder-managed-${ancestor//\//-}-${hazard}"
|
||||
local node
|
||||
write_generated "$home" "$agent"
|
||||
printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
rm -rf "$home/work"
|
||||
|
||||
case "$ancestor" in
|
||||
MOSAIC_HOME) node="$home" ;;
|
||||
MOSAIC_HOME/fleet) node="$home/fleet" ;;
|
||||
MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;;
|
||||
*) fail "unknown managed ancestor: $ancestor" ;;
|
||||
esac
|
||||
|
||||
if [ "$hazard" = symlink ]; then
|
||||
local target="${node}-target"
|
||||
mv "$node" "$target"
|
||||
ln -s "$target" "$node"
|
||||
else
|
||||
chmod 777 "$node"
|
||||
fi
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "${hazard} $ancestor was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
|
||||
for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" symlink
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable
|
||||
done
|
||||
|
||||
# A local file cannot shadow any roster-derived generated key. Validation must
|
||||
# happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SHADOW="$ROOT/shadow"
|
||||
write_generated "$HOME_SHADOW" "coder1"
|
||||
printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if contains_literal "$output" codex; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics
|
||||
# may name the key and hash but must never echo the privileged command text.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_COMMAND="$ROOT/command"
|
||||
write_generated "$HOME_COMMAND" "coder2"
|
||||
COMMAND_VALUE='mosaic yolo codex --dangerous'
|
||||
printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
# Group/world-readable local input is not trusted even when its syntax is safe.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PERMS="$ROOT/perms"
|
||||
write_generated "$HOME_PERMS" "coder3"
|
||||
printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
# control, arbitrary sentinel, or stale bootstrap variables.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic"
|
||||
write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary"
|
||||
PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}"
|
||||
PANE_STALE_HOME="$ROOT/stale-home"
|
||||
PANE_STALE_PATH="$ROOT/stale-bin"
|
||||
PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
# This case does not go through run_start, so its pane binaries come from
|
||||
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
|
||||
# symlinks install_pane_binaries planted under the generated home, which this
|
||||
# launcher never consults because HOME here is the trusted parent. That is a
|
||||
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
|
||||
# this case on the belief that the symlinks cover it would break the #1241
|
||||
# binary check rather than exercise it.
|
||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||
BASH_ENV="$PANE_BASH_ENV" \
|
||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||
HOME="$PANE_STALE_HOME" \
|
||||
PATH="$PANE_STALE_PATH" \
|
||||
/usr/bin/env -i \
|
||||
"HOME=$PANE_TRUSTED_HOME" \
|
||||
"PATH=$FAKE_BIN:/usr/bin:/bin" \
|
||||
"MOSAIC_HOME=$HOME_PANE_BOUNDARY" \
|
||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"MOSAIC_TEST_PANE_PID=$$" \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
# Exercise the repository launcher at $START, not the independently installed
|
||||
# host copy. Set-compare every declared generated projection entry with the
|
||||
# launched process environment so a newly declared identity cannot be omitted
|
||||
# by a hand-maintained per-variable assertion.
|
||||
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
||||
missing_or_changed_generated_environment=$(comm -23 \
|
||||
<(printf '%s\n' "$declared_generated_environment") \
|
||||
<(printf '%s\n' "$pane_environment" | sort))
|
||||
if [ -n "$missing_or_changed_generated_environment" ]; then
|
||||
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
||||
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
||||
fi
|
||||
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
# #1256. On a host with no system Node, tools/install.sh bootstraps one into
|
||||
# ~/.mosaic/node/ and writes that directory to ~/.profile. The fleet unit runs
|
||||
# `env -i ... bash --noprofile --norc`, so ~/.profile is never read — correctly, by
|
||||
# design — and _build_runtime_bin_prefix does not list the bootstrap directory. Its
|
||||
# `npm config get prefix` branch cannot cover the gap either: the installer points
|
||||
# npm's prefix at ~/.npm-global, so that branch contributes the npm-global directory
|
||||
# and never the Node one, however it resolves.
|
||||
#
|
||||
# The property under test is not "the string is in PATH". It is that the pane can
|
||||
# EXECUTE a Node-shebang runtime binary — which is what `mosaic` is
|
||||
# (`#!/usr/bin/env node`) and what actually failed: measured on a greenfield VM as
|
||||
# `env: 'node': No such file or directory` after a clean install that reported success.
|
||||
#
|
||||
# So this case runs the pane for real and requires it to have run. A PATH-substring
|
||||
# assertion would pass on a fix that put the directory in the wrong position, and it
|
||||
# would keep passing if the pane later stopped running for some unrelated reason.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_NODE="$ROOT/bootstrap-node/.config/mosaic"
|
||||
write_generated "$HOME_NODE" "coder-node"
|
||||
NODE_PANE_HOME="${HOME_NODE%/.config/mosaic}"
|
||||
NODE_BOOTSTRAP_BIN="$NODE_PANE_HOME/.mosaic/node/current/bin"
|
||||
mkdir -p "$NODE_BOOTSTRAP_BIN"
|
||||
|
||||
# The bootstrapped runtime. It records that it ran, which is the evidence this case
|
||||
# turns on: no node reachable from the pane means no marker.
|
||||
cat > "$NODE_BOOTSTRAP_BIN/node" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$NODE_BOOTSTRAP_BIN/node"
|
||||
|
||||
# write_generated plants its symlinks under the MOSAIC_HOME it is given; here the
|
||||
# pane's HOME is the trusted parent, so the pane's view of "installed" is this
|
||||
# directory instead. `pi` is what #1241 resolves against PANE_PATH; `mosaic` is what
|
||||
# the pane then executes, and it is a Node script — not a bash script that would run
|
||||
# anywhere and quietly hide the defect.
|
||||
mkdir -p "$NODE_PANE_HOME/.npm-global/bin"
|
||||
ln -sf "$FAKE_BIN/pi" "$NODE_PANE_HOME/.npm-global/bin/pi"
|
||||
printf '#!/usr/bin/env node\n' > "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||
chmod +x "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||
|
||||
# The npm branch is modelled ALIVE and still cannot close the gap, which is the
|
||||
# stronger statement. An earlier draft of this case tried to model npm as absent —
|
||||
# true on a real bootstrap host, where npm lives only in the Node directory — and it
|
||||
# refused to run anywhere npm is in the system path, i.e. most machines. It was also
|
||||
# the weaker claim: it would have proven only that a dead branch supplies nothing.
|
||||
#
|
||||
# On a bootstrap host the installer sets npm's prefix to ~/.npm-global. So even with
|
||||
# `command -v npm` true and the branch executing, `npm config get prefix` yields the
|
||||
# npm-global directory and never the Node one. The gap does not depend on whether
|
||||
# that branch runs.
|
||||
NODE_LAUNCHER_BIN="$ROOT/bootstrap-node-launcher-bin"
|
||||
mkdir -p "$NODE_LAUNCHER_BIN"
|
||||
ln -sf "$FAKE_BIN/tmux" "$NODE_LAUNCHER_BIN/tmux"
|
||||
ln -sf "$FAKE_BIN/npm" "$NODE_LAUNCHER_BIN/npm"
|
||||
|
||||
/usr/bin/env -i \
|
||||
"HOME=$NODE_PANE_HOME" \
|
||||
"PATH=$NODE_LAUNCHER_BIN:/usr/bin:/bin" \
|
||||
"MOSAIC_HOME=$HOME_NODE" \
|
||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||
"MOSAIC_TEST_HOME=$NODE_PANE_HOME" \
|
||||
"MOSAIC_TEST_NPM_PREFIX=$NODE_PANE_HOME/.npm-global" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"MOSAIC_TEST_PANE_PID=$$" \
|
||||
"$START" coder-node
|
||||
|
||||
[ -f "$HOME_NODE/fleet/pane-environment" ] || \
|
||||
fail "pane could not execute a Node-shebang runtime: $NODE_BOOTSTRAP_BIN is absent from PANE_PATH (#1256)"
|
||||
node_pane_environment=$(tr '\0' '\n' < "$HOME_NODE/fleet/pane-environment")
|
||||
# Colon-pad and match a whole element. A regex with `(^|:)` after `.*` looks like it
|
||||
# does this and does not: an anchor cannot match mid-pattern, so it silently requires
|
||||
# a leading colon and rejects the directory in FIRST position — which is where THIS
|
||||
# FIXTURE puts it: it runs under `env -i` with no MOSAIC_RUNTIME_BIN, so the bootstrap
|
||||
# directory leads. That is a property of the fixture, not of the fix — in general the
|
||||
# directory sits second, after MOSAIC_RUNTIME_BIN. The colon padding makes the
|
||||
# assertion position-independent either way, which is why it is written this way and
|
||||
# not with an anchor. That produced a failure reading "pane ran but PANE_PATH does not
|
||||
# carry <dir>" against a PATH whose first element was that dir.
|
||||
node_pane_path=":$(printf '%s\n' "$node_pane_environment" | sed -n 's/^PATH=//p' | head -1):"
|
||||
case "$node_pane_path" in
|
||||
*":$NODE_BOOTSTRAP_BIN:"*) ;;
|
||||
*) fail "pane ran but PANE_PATH does not carry $NODE_BOOTSTRAP_BIN (PATH=$node_pane_path)" ;;
|
||||
esac
|
||||
|
||||
write_interaction_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
}
|
||||
|
||||
run_interaction() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$INTERACTION_START" "$agent"
|
||||
}
|
||||
|
||||
write_heartbeat_local() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/run"
|
||||
cat > "$home/fleet/agents/$agent.env.local" <<EOF
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=$home/run
|
||||
MOSAIC_HEARTBEAT_INTERVAL=1
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
}
|
||||
|
||||
wait_for_sidecar_status() {
|
||||
local file="$1"
|
||||
for _retry in $(seq 1 30); do
|
||||
grep -qF 'status=ok' "$file" 2>/dev/null && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
fail "heartbeat sidecar did not resume after native marker became stale or absent"
|
||||
}
|
||||
|
||||
# A fresh Pi-native marker is authoritative: the shell sidecar may start but
|
||||
# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when
|
||||
# the marker is stale or absent.
|
||||
HOME_NATIVE_FRESH="$ROOT/native-fresh"
|
||||
write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB"
|
||||
touch "$FRESH_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh
|
||||
sleep 0.3
|
||||
fresh_content=$(cat "$FRESH_HB")
|
||||
[ "$fresh_content" = 'ts=native
|
||||
pid=1
|
||||
status=busy
|
||||
model=authoritative-model' ] || fail "fresh native heartbeat was overwritten"
|
||||
|
||||
HOME_NATIVE_STALE="$ROOT/native-stale"
|
||||
write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -t 200001010000.00 "$STALE_HB.native"
|
||||
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent
|
||||
wait_for_sidecar_status "$ABSENT_HB"
|
||||
|
||||
# The interaction wrapper delegates to the shared strict parser before applying
|
||||
# its pinned policy, so malformed projection data wins over profile diagnostics.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed"
|
||||
write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed"
|
||||
printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_literal "$interaction_policy_args" new-session && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
|
||||
# missing from that path is a launch failure, and it has to be named before the
|
||||
# session is created — after it, the diagnostic dies with the pane.
|
||||
assert_missing_pane_binary_rejected() {
|
||||
local binary="$1"
|
||||
local home="$ROOT/missing-$binary"
|
||||
local agent="coder-missing-$binary"
|
||||
write_generated "$home" "$agent"
|
||||
rm -f "$home/.npm-global/bin/$binary"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
local output
|
||||
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
|
||||
fail "launch succeeded with '$binary' absent from the pane PATH"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
|
||||
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
|
||||
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||
fail "launcher created a session it knew would die ($binary absent)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_missing_pane_binary_rejected mosaic
|
||||
assert_missing_pane_binary_rejected pi
|
||||
|
||||
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
|
||||
# second after new-session means the runtime died on startup. This used to be a
|
||||
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||
# then reported a fleet that was not running.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_DEAD_PANE="$ROOT/dead-pane"
|
||||
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
|
||||
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
|
||||
fail "launcher reported success over a pane that did not survive"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
|
||||
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||
fail "dead pane is still being reported as a heartbeat-sidecar problem"
|
||||
fi
|
||||
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
|
||||
fail "dead-pane case did not reach the launch it is measuring"
|
||||
|
||||
# #1241, the other way a pane fails. Above, tmux destroyed the session and
|
||||
# has-session said so. Here the session is still there and no PID comes back
|
||||
# after the retries — a different fault (the pane is alive but unusable, or
|
||||
# tmux is answering inconsistently) that an operator has to be told apart from
|
||||
# a runtime that died on startup.
|
||||
#
|
||||
# This case exists because the branch that handles it shipped with nothing able
|
||||
# to reach it: the shim answered has-session only for the holder, so every
|
||||
# non-holder agent landed in the session-is-gone branch no matter what. A
|
||||
# defensive branch nothing exercises is the same shape as the bug this whole
|
||||
# change is about, one layer down.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_NO_PID="$ROOT/pane-no-pid"
|
||||
write_generated "$HOME_NO_PID" "coder-no-pid"
|
||||
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
|
||||
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
|
||||
fail "launcher reported success over a session with no resolvable pane PID"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
|
||||
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
|
||||
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
|
||||
fail "a session tmux still reports was diagnosed as a destroyed session"
|
||||
fi
|
||||
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
|
||||
fi
|
||||
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
# projection and ignores an ambient socket supplied by the caller.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_STOP="$ROOT/stop"
|
||||
write_generated "$HOME_STOP" "coder-stop"
|
||||
HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$HOME_STOP" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if contains_literal "$stop_args" ambient-socket; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
Reference in New Issue
Block a user