feat(fleet): enforce generated environment boundary (#772)
This commit was merged in pull request #772.
This commit is contained in:
@@ -9,7 +9,8 @@ package, normally at:
|
||||
```
|
||||
|
||||
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
||||
default tmux server.
|
||||
default tmux server. The roster is the desired-state authority; generated environment files are
|
||||
rebuildable projections, never a second source of configuration.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -31,6 +32,17 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||
or prints credential variables.
|
||||
|
||||
## Generated agent environment boundary
|
||||
|
||||
`mosaic fleet install` writes a private deterministic projection at
|
||||
`~/.config/mosaic/fleet/agents/<agent>.env.generated`. It may relocate only approved local machine
|
||||
data to `<agent>.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys,
|
||||
unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is
|
||||
regenerated, relocated, or quarantined and is not a launch authority.
|
||||
|
||||
See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md)
|
||||
for allowed local keys and the USC downstream interface evidence.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,45 +24,56 @@ The agent template calls:
|
||||
|
||||
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
||||
|
||||
## Local customization
|
||||
## Generated environment and local data
|
||||
|
||||
Per-agent overrides live outside the package in:
|
||||
The roster-derived projection is written outside the package at:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.generated
|
||||
```
|
||||
|
||||
Example:
|
||||
Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap
|
||||
environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh`
|
||||
strictly parses the generated projection and the optional local data file:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
MOSAIC_AGENT_RUNTIME=claude
|
||||
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
||||
# Optional escape hatch for PoC/canary agents:
|
||||
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.local
|
||||
```
|
||||
|
||||
The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or
|
||||
interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command,
|
||||
or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the
|
||||
generated projection; update the roster and regenerate it instead. A legacy `<agent>.env` is
|
||||
consumed only for regeneration, strict relocation, or private quarantine and is never launch input.
|
||||
|
||||
See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
||||
|
||||
## Manual canary sequence
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-tmux-holder.service
|
||||
systemctl --user start mosaic-agent@canary.service
|
||||
tmux -L mosaic-fleet ls
|
||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||
helpers, and writes private roster-derived projections before any service is started.
|
||||
|
||||
# For an operator-interaction service, the roster/env identity selects the
|
||||
# generic unit instance; no service source is renamed for an instance.
|
||||
```bash
|
||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||
mosaic fleet init --profile minimal --write
|
||||
mosaic fleet install
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start canary-pi
|
||||
tmux -L mosaic-fleet ls
|
||||
```
|
||||
|
||||
For an operator-interaction service, first put `<agent-name>` in the roster with the pinned Pi
|
||||
runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after
|
||||
that roster change so it writes `<agent-name>.env.generated`; ambient `MOSAIC_AGENT_*` values are not
|
||||
launch authority. The generic unit instance uses that generated identity, and no service source is
|
||||
renamed for an instance:
|
||||
|
||||
```bash
|
||||
mosaic fleet install
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||
MOSAIC_AGENT_NAME=<agent-name> \
|
||||
MOSAIC_AGENT_RUNTIME=pi \
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||
MOSAIC_AGENT_REASONING=high \
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
|
||||
```
|
||||
|
||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||
|
||||
@@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
|
||||
# default tmux socket (no -L). The per-agent .env sets it when the roster names
|
||||
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
# Never preload the projection. The launcher starts from a fixed minimal
|
||||
# environment and strictly validates generated/local data before tmux effects.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -7,11 +7,13 @@ PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
# The interaction wrapper delegates to the shared strict parser before pinned
|
||||
# profile checks; no projection data reaches Bash through systemd.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -6,10 +6,11 @@ After=default.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
Environment=MOSAIC_TMUX_HOLDER=_holder
|
||||
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
# The holder owns the tmux server, so clear loader, shell-control, and stale
|
||||
# manager/session variables before the server process starts.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh
|
||||
ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -5,6 +5,8 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||
HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh"
|
||||
START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -14,16 +16,40 @@ fail() {
|
||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||
[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh"
|
||||
[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh"
|
||||
|
||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \
|
||||
fail "holder does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \
|
||||
fail "holder does not clear manager environment before starting tmux"
|
||||
grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \
|
||||
fail "holder stop does not clear manager environment"
|
||||
if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
fi
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \
|
||||
fail "agent unit does not remove loader and shell-control variables"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \
|
||||
fail "interaction unit does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \
|
||||
fail "agent unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \
|
||||
fail "agent stop does not use the validated exact-stop path"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \
|
||||
fail "interaction unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \
|
||||
fail "interaction stop does not use the validated exact-stop path"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
@@ -32,4 +58,102 @@ if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
}
|
||||
fi
|
||||
|
||||
# Real isolated socket regression: a preexisting server with an LD_PRELOAD
|
||||
# constructor marker must fail closed, while a fresh named server is created.
|
||||
if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
TEST_SOCKET="mosaic-holder-test-$$"
|
||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||
MARKER="$TEST_ROOT/loader-marker"
|
||||
LIBRARY="$TEST_ROOT/marker.so"
|
||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
cat > "$TEST_ROOT/marker.c" <<'EOF'
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
__attribute__((constructor)) static void mark_loader(void) {
|
||||
const char *path = getenv("MOSAIC_LOADER_MARKER");
|
||||
if (path != NULL) {
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
|
||||
if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||
: > "$MARKER"
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \
|
||||
>"$TEST_ROOT/holder.out" 2>&1; then
|
||||
fail "holder adopted contaminated named server"
|
||||
fi
|
||||
grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \
|
||||
fail "holder did not report contaminated server environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "holder replaced a contaminated server instead of failing closed"
|
||||
[ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader"
|
||||
|
||||
# Agent validation must reject the same unmanaged server without cleaning its
|
||||
# global environment or adding a managed session.
|
||||
AGENT_HOME="$HOLDER_HOME/.config/mosaic"
|
||||
AGENT_NAME=loader-safe
|
||||
AGENT_WORKDIR="$AGENT_HOME/work"
|
||||
AGENT_BIN="$TEST_ROOT/agent-bin"
|
||||
mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN"
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$AGENT_WORKDIR
|
||||
MOSAIC_TMUX_SOCKET=$TEST_SOCKET
|
||||
EOF
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$AGENT_BIN" > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \
|
||||
"$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
cat > "$AGENT_BIN/mosaic" <<'EOF'
|
||||
#!/bin/sh
|
||||
sleep 30
|
||||
EOF
|
||||
chmod 700 "$AGENT_BIN/mosaic"
|
||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then
|
||||
fail "agent launcher adopted contaminated named server"
|
||||
fi
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "agent launcher changed unmanaged server PID"
|
||||
[ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \
|
||||
fail "agent launcher changed unmanaged global environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \
|
||||
fail "agent launcher changed unmanaged sessions"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||
fail "fresh holder retained LD_PRELOAD"
|
||||
fi
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME"
|
||||
tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \
|
||||
fail "agent did not launch on a valid owned server"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
trap - EXIT
|
||||
rm -rf "$TEST_ROOT"
|
||||
fi
|
||||
|
||||
echo "ok - fleet systemd unit templates"
|
||||
|
||||
@@ -1,39 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||
# socket_name is honored when set; absent never silently becomes mosaic-fleet
|
||||
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||
# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
|
||||
# separately parsed data-only .env.local can influence launch. Never source an
|
||||
# environment file and never accept a command string from either file.
|
||||
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
case "$MOSAIC_AGENT_REASONING" in
|
||||
''|low|medium|high) ;;
|
||||
*)
|
||||
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||
exit 64
|
||||
MODE=launch
|
||||
case "${1:-}" in
|
||||
--stop)
|
||||
MODE=stop
|
||||
AGENT_NAME=${2:-}
|
||||
;;
|
||||
--interaction)
|
||||
MODE=interaction
|
||||
AGENT_NAME=${2:-}
|
||||
;;
|
||||
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
|
||||
esac
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
hash_value() {
|
||||
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
fail_env() {
|
||||
local code="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
safe_agent_name() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
|
||||
}
|
||||
|
||||
safe_policy_name() {
|
||||
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
|
||||
}
|
||||
|
||||
safe_path() {
|
||||
[[ "$1" == /* ]] || return 1
|
||||
[[ "$1" != *".."* ]] || return 1
|
||||
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
|
||||
}
|
||||
|
||||
assert_private_regular_file() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
|
||||
}
|
||||
|
||||
assert_managed_directory() {
|
||||
local directory="$1"
|
||||
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
assert_private_directory() {
|
||||
local directory="$1"
|
||||
assert_managed_directory "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
|
||||
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
|
||||
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
||||
|
||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
||||
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
||||
assert_managed_directory "$MOSAIC_HOME"
|
||||
assert_managed_directory "$FLEET_DIR"
|
||||
assert_private_directory "$AGENT_ENV_DIR"
|
||||
|
||||
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
|
||||
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
|
||||
|
||||
declare -A GENERATED_VALUES=()
|
||||
declare -A LOCAL_VALUES=()
|
||||
declare -A SEEN_KEYS=()
|
||||
|
||||
is_sensitive_key() {
|
||||
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
|
||||
}
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_local_key() {
|
||||
case "$1" in
|
||||
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_generated_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
|
||||
MOSAIC_AGENT_REASONING)
|
||||
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
|
||||
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
|
||||
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_local_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
|
||||
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
|
||||
else
|
||||
safe_path "$value" || fail_env unsafe-path "$key" "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
load_environment_file() {
|
||||
local file="$1"
|
||||
local kind="$2"
|
||||
[ -e "$file" ] || {
|
||||
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
|
||||
return 0
|
||||
}
|
||||
assert_private_regular_file "$file"
|
||||
SEEN_KEYS=()
|
||||
|
||||
local line key value
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -z "$line" ] && continue
|
||||
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
|
||||
fail_env malformed-line '(malformed)' "$line"
|
||||
fi
|
||||
key=${BASH_REMATCH[1]}
|
||||
value=${BASH_REMATCH[2]}
|
||||
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
|
||||
SEEN_KEYS[$key]=1
|
||||
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
|
||||
|
||||
if [ "$kind" = generated ]; then
|
||||
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_generated_value "$key" "$value"
|
||||
GENERATED_VALUES[$key]=$value
|
||||
else
|
||||
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
|
||||
is_local_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_local_value "$key" "$value"
|
||||
LOCAL_VALUES[$key]=$value
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
|
||||
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
|
||||
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux is required" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||
_tmux() {
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
@@ -42,140 +210,90 @@ _tmux() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_owned_tmux_server() {
|
||||
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
|
||||
local owner_mode
|
||||
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
|
||||
local owner
|
||||
owner=$(tr -d '\n' < "$owner_file")
|
||||
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
|
||||
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
|
||||
local environment expected
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$owner" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "tmux server ownership or environment validation failed"
|
||||
}
|
||||
|
||||
# Validate exact server ownership before querying, cleaning, or creating any
|
||||
# managed session. An unmanaged or contaminated named socket is never repaired.
|
||||
assert_owned_tmux_server
|
||||
|
||||
if [ "$MODE" = interaction ]; then
|
||||
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
|
||||
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
|
||||
fail "operator interaction service requires the pinned model"
|
||||
[ "$MOSAIC_AGENT_REASONING" = high ] || \
|
||||
fail "operator interaction service requires high reasoning"
|
||||
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
|
||||
fail "operator interaction service requires the operator-interaction tool policy"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = stop ]; then
|
||||
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||
fi
|
||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||
# that home. Derive the pane home from the canonical path when available so an
|
||||
# inherited pane/session HOME cannot become runtime authority.
|
||||
PANE_HOME=$HOME
|
||||
case "$MOSAIC_HOME" in
|
||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||
esac
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
# Precedence:
|
||||
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||
#
|
||||
# Only directories that already exist are included. The prefix is baked into
|
||||
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||
# so every existing candidate must always be included. Dedup within the
|
||||
# constructed prefix avoids listing the same dir twice.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
|
||||
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||
fi
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then
|
||||
candidates+=("${npm_prefix}/bin")
|
||||
fi
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
candidates+=("$HOME/.npm-global/bin")
|
||||
candidates+=("$HOME/.local/bin")
|
||||
|
||||
local prefix=""
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix="$dir"
|
||||
else
|
||||
case ":${prefix}:" in
|
||||
*":${dir}:"*) ;; # already in our prefix — skip
|
||||
*) prefix="${prefix}:${dir}" ;;
|
||||
esac
|
||||
fi
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# ── Build the pane command ────────────────────────────────────────────────────
|
||||
# The pane command must:
|
||||
# - Export the augmented PATH so the runtime binary is found.
|
||||
# - exec the agent command so the runtime is the pane's foreground process
|
||||
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||
#
|
||||
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||
# NOW (in this script) because the pane shell inherits the tmux server
|
||||
# environment, not this script's env.
|
||||
#
|
||||
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||
# to avoid nested quoting problems.
|
||||
#
|
||||
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||
# server environment (not this script's, and not the systemd unit's), so the
|
||||
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||
# safe single bash token regardless of the name's characters.
|
||||
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
|
||||
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
|
||||
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
|
||||
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
|
||||
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
|
||||
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
|
||||
# (compose-contract -> readPersonaContractBlock); without this export it sees an
|
||||
# undefined class and silently injects NO persona contract. %q-quote it so it is a
|
||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
|
||||
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
|
||||
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
|
||||
# the first time it opens a directory. A fleet-launched agent has no human to
|
||||
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
|
||||
# reporting "healthy" (the pane process IS alive — it's just blocked).
|
||||
#
|
||||
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
|
||||
# neither does `trustedProjectDirectories` in settings.json (verified empirically
|
||||
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
|
||||
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
|
||||
# answering the prompt writes). So we pre-seed that record here.
|
||||
#
|
||||
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
|
||||
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
|
||||
# Only the claude runtime needs this; codex/pi have no such gate.
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
# The path claude keys on is the resolved cwd it is launched in.
|
||||
local rp
|
||||
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
|
||||
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
|
||||
local lock="${claude_json}.mosaic-lock"
|
||||
_seed() {
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
cj = os.environ["MOSAIC_CJ"]
|
||||
d = os.environ["MOSAIC_TRUST_DIR"]
|
||||
@@ -184,22 +302,19 @@ try:
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
# Never corrupt an unreadable/partial file — bail without writing.
|
||||
sys.exit(2)
|
||||
projects = data.setdefault("projects", {})
|
||||
entry = projects.get(d)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
projects[d] = entry
|
||||
if entry.get("hasTrustDialogAccepted") is True:
|
||||
sys.exit(0) # already trusted — nothing to do
|
||||
entry["hasTrustDialogAccepted"] = True
|
||||
tmp_dir = os.path.dirname(cj) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, cj) # atomic
|
||||
os.replace(tmp, cj)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
@@ -207,56 +322,56 @@ except Exception:
|
||||
pass
|
||||
sys.exit(3)
|
||||
PY
|
||||
}
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
|
||||
else
|
||||
_seed
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MOSAIC_AGENT_RUNTIME" in
|
||||
claude)
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \
|
||||
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
;;
|
||||
esac
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
||||
# so server/session variables cannot cross the launch boundary; retain only
|
||||
# trusted bootstrap, generated, and approved local data as argv assignments.
|
||||
LAUNCH_ENV=(
|
||||
/usr/bin/env
|
||||
-i
|
||||
"HOME=$PANE_HOME"
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
|
||||
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
|
||||
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
|
||||
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
|
||||
)
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||
bash -c "$PANE_SHELL_SNIPPET"
|
||||
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
||||
|
||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||
PANE_PID=""
|
||||
for _retry in 1 2 3 4 5; do
|
||||
PANE_PID=$(_tmux list-panes \
|
||||
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
[ -n "$PANE_PID" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||
_start_heartbeat_sidecar() {
|
||||
local agent="$1"
|
||||
local pane_pid="$2"
|
||||
local run_dir="$3"
|
||||
local interval="$4"
|
||||
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
||||
local hb_file="${run_dir}/${agent}.hb"
|
||||
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||
# references to any variables from this script's environment.
|
||||
local sidecar_script
|
||||
sidecar_script=$(printf \
|
||||
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||
'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
|
||||
"$hb_file" "$pane_pid" "$interval")
|
||||
|
||||
# setsid + disown ensures the sidecar survives this script exiting.
|
||||
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
else
|
||||
@@ -266,7 +381,6 @@ _start_heartbeat_sidecar() {
|
||||
}
|
||||
|
||||
if [ -n "$PANE_PID" ]; then
|
||||
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||
|
||||
@@ -9,11 +9,7 @@ fail() {
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||
[ "${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"
|
||||
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||
# 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"
|
||||
|
||||
64
packages/mosaic/framework/tools/fleet/start-tmux-holder.sh
Executable file
64
packages/mosaic/framework/tools/fleet/start-tmux-holder.sh
Executable file
@@ -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
|
||||
@@ -3,373 +3,410 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||
AGENT="agent-$RANDOM"
|
||||
WORKDIR=$(mktemp -d)
|
||||
|
||||
# Keep a single cleanup trap that accumulates resources.
|
||||
CLEANUP_DIRS=("$WORKDIR")
|
||||
CLEANUP_SOCKETS=("$SOCKET")
|
||||
trap '_cleanup' EXIT
|
||||
_cleanup() {
|
||||
for s in "${CLEANUP_SOCKETS[@]:-}"; do
|
||||
tmux -L "$s" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
for d in "${CLEANUP_DIRS[@]:-}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT"
|
||||
|
||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
|
||||
# process establishes its own cwd (the -c start dir). Poll until it settles.
|
||||
actual_dir=""
|
||||
for _ in $(seq 1 30); do
|
||||
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||
[ "$actual_dir" = "$WORKDIR" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
|
||||
|
||||
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
|
||||
|
||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||
|
||||
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
|
||||
#
|
||||
# We capture the command the script would hand to tmux by injecting a fake
|
||||
# 'tmux' shim into PATH. The shim:
|
||||
# - Intercepts 'new-session' calls and records its arguments to a file.
|
||||
# - For 'has-session' calls, exits 1 (session does not exist) so the script
|
||||
# proceeds to launch instead of printing "already running".
|
||||
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
|
||||
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
|
||||
# test 6 and 7 cover that path). This prevents any real-filesystem side
|
||||
# effects or leaked background processes.
|
||||
# - For all other subcommands, exits 0.
|
||||
#
|
||||
# Assertions:
|
||||
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
|
||||
# b) 'exec' appears so the runtime replaces the wrapper shell.
|
||||
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
|
||||
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN=$(mktemp -d)
|
||||
TMUX_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
|
||||
|
||||
# Write the fake tmux shim (uses only positional args, no sourced vars).
|
||||
cat > "$FAKE_BIN/tmux" <<SHIM
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# Fake tmux: record new-session args; report has-session as missing.
|
||||
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
|
||||
if [ "\$subcmd" = "has-session" ]; then
|
||||
exit 1 # session not found → script will attempt new-session
|
||||
fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
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)
|
||||
for argument in "${args[@]}"; do
|
||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||
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"
|
||||
|
||||
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
|
||||
AGENT3="agent3-$RANDOM"
|
||||
WORKDIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR3")
|
||||
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_AGENT_CLASS="code" \
|
||||
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||
"$START" "$AGENT3"
|
||||
|
||||
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE"
|
||||
|
||||
echo "--- captured tmux new-session args ---"
|
||||
echo "$all_args"
|
||||
echo "--- end args ---"
|
||||
|
||||
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
|
||||
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
|
||||
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
|
||||
|
||||
# b) exec must appear so the runtime replaces the wrapper shell.
|
||||
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
|
||||
|
||||
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
|
||||
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
|
||||
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
|
||||
|
||||
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
|
||||
# INTO the pane. The pane inherits the tmux SERVER environment (not this
|
||||
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
|
||||
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
|
||||
# load-bearing: the launcher composes the persona contract from
|
||||
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
|
||||
# persona (regression guard for the A3a pane-propagation gap).
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||
|
||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||
TMUX_ARGS_FILE2=$(mktemp)
|
||||
FAKE_BIN2=$(mktemp -d)
|
||||
HB_RUN_DIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
|
||||
|
||||
cat > "$FAKE_BIN2/tmux" <<SHIM2
|
||||
cat > "$FAKE_BIN/mosaic" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
|
||||
exit 0
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
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_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"
|
||||
}
|
||||
|
||||
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_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"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
|
||||
# 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.
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
|
||||
# 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
|
||||
exit 0
|
||||
SHIM2
|
||||
chmod +x "$FAKE_BIN2/tmux"
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
|
||||
AGENT4="agent4-$RANDOM"
|
||||
WORKDIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR4")
|
||||
|
||||
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
|
||||
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
|
||||
PATH="$FAKE_BIN2:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET4" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
|
||||
"$START" "$AGENT4"
|
||||
|
||||
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE2"
|
||||
rm -rf "$WORKDIR4"
|
||||
|
||||
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
|
||||
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
|
||||
|
||||
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
|
||||
#
|
||||
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
|
||||
# a candidate because it was already present in the launcher process's $PATH.
|
||||
# That check was wrong: the pane inherits the tmux SERVER environment, not the
|
||||
# launcher's env. Even if a dir is on the launcher's PATH it must always be
|
||||
# baked into the pane's PATH export.
|
||||
#
|
||||
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
|
||||
# then asserting the generated new-session command still exports it.
|
||||
TMUX_ARGS_FILE5=$(mktemp)
|
||||
FAKE_BIN5=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
|
||||
HB_RUN_DIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
|
||||
|
||||
cat > "$FAKE_BIN5/tmux" <<SHIM5
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
|
||||
exit 0
|
||||
: > "$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
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM5
|
||||
chmod +x "$FAKE_BIN5/tmux"
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
|
||||
AGENT5="agent5-$RANDOM"
|
||||
WORKDIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR5")
|
||||
CLEANUP_SOCKETS+=("$SOCKET5")
|
||||
# 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"
|
||||
|
||||
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
|
||||
# old (buggy) code would have skipped it. The correct code must still include
|
||||
# it in the pane PATH export.
|
||||
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET5" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
|
||||
"$START" "$AGENT5"
|
||||
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
|
||||
|
||||
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE5"
|
||||
rm -rf "$WORKDIR5"
|
||||
if [ "$hazard" = symlink ]; then
|
||||
local target="${node}-target"
|
||||
mv "$node" "$target"
|
||||
ln -s "$target" "$node"
|
||||
else
|
||||
chmod 777 "$node"
|
||||
fi
|
||||
|
||||
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
|
||||
echo "$all_args5"
|
||||
echo "--- end test 5 args ---"
|
||||
: > "$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"
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "$all_args5" | grep -qF "export PATH=" || \
|
||||
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
|
||||
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
|
||||
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
|
||||
|
||||
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
|
||||
#
|
||||
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
|
||||
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
|
||||
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
|
||||
# to appear and check its content.
|
||||
|
||||
HB_RUN_DIR=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$HB_RUN_DIR")
|
||||
|
||||
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
|
||||
# We need to invoke the script for a NEW agent on the same socket to exercise
|
||||
# the heartbeat path with a real pane PID.
|
||||
AGENT6="agent6-$RANDOM"
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="1" \
|
||||
"$START" "$AGENT6"
|
||||
|
||||
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
|
||||
|
||||
# Wait up to 5 seconds for the heartbeat file to appear.
|
||||
_waited=0
|
||||
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
|
||||
sleep 0.5
|
||||
_waited=$((_waited + 1))
|
||||
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
|
||||
|
||||
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
|
||||
# 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"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
hb_content=$(cat "$HB_FILE")
|
||||
echo "--- test 6: heartbeat file content ---"
|
||||
echo "$hb_content"
|
||||
echo "--- end test 6 ---"
|
||||
# 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"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
# Verify required fields are present.
|
||||
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
|
||||
fail "test6: heartbeat ts field missing or malformed"
|
||||
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
|
||||
fail "test6: heartbeat pid field missing or malformed"
|
||||
echo "$hb_content" | grep -qF 'status=ok' || \
|
||||
fail "test6: heartbeat status=ok missing"
|
||||
# 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"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
|
||||
#
|
||||
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
|
||||
# invocation without needing a real session. A fake setsid shim records its
|
||||
# arguments so we can assert the sidecar script targets the expected .hb path
|
||||
# and uses the configured interval.
|
||||
# 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"
|
||||
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 \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
FAKE_BIN7=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN7=$(mktemp -d)
|
||||
SETSID_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
AGENT7="my-fleet-agent-$RANDOM"
|
||||
INTERVAL7="42"
|
||||
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_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"
|
||||
}
|
||||
|
||||
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
|
||||
cat > "$FAKE_BIN7/tmux" <<SHIM7
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
|
||||
exit 0
|
||||
SHIM7
|
||||
chmod +x "$FAKE_BIN7/tmux"
|
||||
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"
|
||||
}
|
||||
|
||||
# Fake setsid: capture the bash -c <script> argument for inspection, then
|
||||
# background an actual bash subshell so disown succeeds in the caller.
|
||||
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# argv: setsid bash -c <sidecar_script>
|
||||
# Record the full argument list to the capture file, then exit cleanly.
|
||||
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
|
||||
exit 0
|
||||
SETSID_SHIM
|
||||
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
|
||||
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
|
||||
chmod +x "$FAKE_BIN7/setsid"
|
||||
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"
|
||||
}
|
||||
|
||||
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
|
||||
WORKDIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR7")
|
||||
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"
|
||||
}
|
||||
|
||||
PATH="$FAKE_BIN7:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET7" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
|
||||
"$START" "$AGENT7"
|
||||
# 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"
|
||||
|
||||
# Give the background setsid shim a moment to finish writing the capture file.
|
||||
sleep 0.5
|
||||
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 -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
|
||||
rm -f "$SETSID_ARGS_FILE"
|
||||
rm -rf "$WORKDIR7"
|
||||
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"
|
||||
|
||||
echo "--- test 7: captured setsid args ---"
|
||||
echo "$setsid_args"
|
||||
echo "--- end test 7 ---"
|
||||
# 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"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# The sidecar script (bash -c <script>) must reference the correct .hb path.
|
||||
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
|
||||
echo "$setsid_args" | grep -qF "$expected_hb" || \
|
||||
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
|
||||
# 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"
|
||||
perl -0pi -e '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")
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# The sidecar script must use the configured interval.
|
||||
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
|
||||
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
|
||||
# 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")
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
echo "ok - start-agent-session"
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
|
||||
Reference in New Issue
Block a user