Files
stack/scripts/agent.sh
T
jason.woltje d1d7b5598d feat(agent): fail-closed seat resolution under MOSAIC_AGENTS_DIR override (#46)
Owner decision after live verification of M18: an explicit agents-dir
override that cannot resolve the named seat now refuses the launch
(exit 4, names the seat and dir) instead of launching seatless and
unbounded. Unsetting the override keeps the M13 plain governed TUI.
MOSAIC_ROLES_DIR needs no symmetric change - the M18 gate already
refuses unresolvable role contracts.

Task suite 88 -> 90 (refusal + refusal-names-the-seat). TOOLS.md Agent
section documents the refusal.

Suites 24/90/14/17 + verify green.
2026-09-03 19:45:10 -05:00

181 lines
8.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Launch an interactive (TUI) Mosaic agent in its container.
#
# Usage:
# scripts/agent.sh <name> [--mission <file>] [--workspace <ws>]
# [--session <name>] [--tools <comma,list>]
#
# The agent receives the four immutable contracts (constitution, standards,
# SOUL, USER) plus its own identity and optional mission directives as its
# system prompt, a persistent named session, and - if declared - a
# workspace and tool capabilities. The TUI opens clean; you drive.
#
# This is the Mosaic alternative to launching vanilla pi: same engine,
# governed context.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=common.sh
source scripts/common.sh
NAME=""
MISSION=""
WORKSPACE=""
SESSION=""
TOOLS=""
SKILLS=""
while [ $# -gt 0 ]; do
case "$1" in
--mission) MISSION="${2:?}"; shift 2 ;;
--workspace) WORKSPACE="${2:?}"; shift 2 ;;
--session) SESSION="${2:?}"; shift 2 ;;
--tools) TOOLS="${2:?}"; shift 2 ;;
--skills) SKILLS="${2:?}"; shift 2 ;;
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
*) NAME="$1"; shift ;;
esac
done
[ -n "$NAME" ] || { echo "agent: usage: scripts/agent.sh <name> [--mission f] [--workspace ws] [--session s] [--tools list]" >&2; exit 4; }
case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit 4;; esac
load_config
load_release
bootstrap_runtime_dir
ensure_release_aligned
# Onboarding gate (M16): a TUI agent cannot launch without a user profile.
# The onboarding wizard runs automatically here - the TTY is already yours.
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
echo "agent: no user profile found - starting onboarding"
scripts/onboard.sh
[ -f "$MOSAIC_DEV_DIR/user/USER.md" ] || { echo "agent: onboarding did not complete; aborting launch" >&2; exit 1; }
fi
# Agent seat definition (M15): when agents/<name>/agent.json exists it is
# strictly validated and its values become defaults (CLI flags override).
# The seat's SOUL.md overrides the contract persona; governance contracts
# are never overridden.
AGENTS_DIR="${MOSAIC_AGENTS_DIR:-agents}"
# Fail closed (owner decision, #46): an explicit agents-dir override that
# cannot resolve the named seat refuses the launch - a seatless launch has
# no identity, no SOUL, and no role ceiling to bind. Unset the override for
# the M13 plain governed TUI.
if [ -n "${MOSAIC_AGENTS_DIR:-}" ] && [ ! -f "$AGENTS_DIR/$NAME/agent.json" ]; then
echo "agent: no seat definition for '$NAME' in overridden agents dir: $AGENTS_DIR (unset MOSAIC_AGENTS_DIR for a plain governed TUI)" >&2
exit 4
fi
ROLE=""
DEFCAPS=""
AGENT_DEF_SKILLS=""
AGENT_DEF_SKILLS=""
if [ -f "$AGENTS_DIR/$NAME/agent.json" ]; then
DEFAULTS_FILE="$(mktemp)"
node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
if (p.agentVersion !== 1) process.exit(2);
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
if (typeof p.name !== "string" || !ID.test(p.name)) process.exit(2);
if (p.role !== undefined && (typeof p.role !== "string" || !ID.test(p.role))) process.exit(2);
let tools = "";
if (p.capabilities !== undefined) {
if (typeof p.capabilities !== "object" || p.capabilities === null || Array.isArray(p.capabilities)) process.exit(2);
for (const k of Object.keys(p.capabilities)) if (k !== "tools") process.exit(2);
if (!Array.isArray(p.capabilities.tools) || p.capabilities.tools.some(t => !/^[a-z]+$/.test(t))) process.exit(2);
tools = p.capabilities.tools.join(",");
}
fs.writeFileSync(process.argv[2], "AGENT_DEF_ROLE=" + (p.role || "") + "\nAGENT_DEF_CAPS=" + tools + "\nAGENT_DEF_SKILLS=" + ((p.skills && Array.isArray(p.skills)) ? p.skills.join(",") : "") + "\n");
' "$AGENTS_DIR/$NAME/agent.json" "$DEFAULTS_FILE" || { rm -f "$DEFAULTS_FILE"; echo "agent: invalid agent definition" >&2; exit 2; }
AGENT_DEF_ROLE=""; AGENT_DEF_CAPS=""; AGENT_DEF_SKILLS=""
while IFS= read -r line; do
case "$line" in
AGENT_DEF_ROLE=*) AGENT_DEF_ROLE="${line#AGENT_DEF_ROLE=}" ;;
AGENT_DEF_CAPS=*) AGENT_DEF_CAPS="${line#AGENT_DEF_CAPS=}" ;;
AGENT_DEF_SKILLS=*) AGENT_DEF_SKILLS="${line#AGENT_DEF_SKILLS=}" ;;
esac
done < "$DEFAULTS_FILE"
rm -f "$DEFAULTS_FILE"
ROLE="$AGENT_DEF_ROLE"
DEFCAPS="$AGENT_DEF_CAPS"
[ -r "$AGENTS_DIR/$NAME/SOUL.md" ] || { echo "agent: definition dir missing SOUL.md: $AGENTS_DIR/$NAME" >&2; exit 4; }
mkdir -p "$MOSAIC_DEV_DIR/agents/$NAME"
cp "$AGENTS_DIR/$NAME/SOUL.md" "$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md"
export MOSAIC_AGENT_SOUL_FILE="/var/lib/mosaic/agents/$NAME/SOUL.md"
# Seat record: written once at instantiation.
SEAT="$MOSAIC_DEV_DIR/agents/$NAME/seat.json"
if [ ! -f "$SEAT" ]; then
printf '{"seatVersion":1,"name":"%s","role":"%s","instantiatedAt":"%s"}\n' \
"$NAME" "$ROLE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SEAT"
fi
fi
SESSION="${SESSION:-agent-$NAME}"
mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION"
export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION"
export MOSAIC_AGENT_NAME="$NAME"
[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE"
export MOSAIC_INTERACTIVE=1
if [ -z "$TOOLS" ] && [ -n "$DEFCAPS" ]; then TOOLS="$DEFCAPS"; fi
# Role ceiling (M18): a declared role binds to roles/<role>.json; its tools
# are a ceiling that the seat definition or CLI may narrow, never escalate
# past. A missing or invalid contract refuses the launch - a declared role
# that resolves to nothing is the under-equipped-seat failure mode.
if [ -n "$ROLE" ]; then
ROLES_DIR="${MOSAIC_ROLES_DIR:-roles}"
ROLE_FILE="$ROLES_DIR/$ROLE.json"
[ -r "$ROLE_FILE" ] || { echo "agent: role '$ROLE' is declared but has no contract: $ROLE_FILE" >&2; exit 2; }
ROLE_OUT="$(node scripts/mosaic-task.mjs resolve-role "$ROLE_FILE")" || { echo "agent: invalid role contract: $ROLE_FILE" >&2; exit 2; }
ROLE_CEILING="$(printf '%s\n' "$ROLE_OUT" | sed -n 's/^MOSAIC_ROLE_TOOLS=//p')"
if [ -n "$TOOLS" ]; then
REQUESTED_TOOLS="$TOOLS"
TOOLS="$(node -e 'const c=process.argv[1].split(",").filter(Boolean);const r=process.argv[2].split(",").filter(Boolean);process.stdout.write(r.filter(t=>c.includes(t)).join(","))' "$ROLE_CEILING" "$REQUESTED_TOOLS")"
if [ -z "$TOOLS" ]; then
echo "agent: capability policy: role '$ROLE' ceiling and requested tools have nothing in common -> tool-free seat" >&2
elif [ "$TOOLS" != "$REQUESTED_TOOLS" ]; then
echo "agent: capability policy: role '$ROLE' ceiling narrowed tools -> $TOOLS" >&2
fi
else
TOOLS="$ROLE_CEILING"
fi
fi
export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
# Skills (M17): seat definition may declare skill names; each must be
# enabled in <dataRoot>/skills-enabled or the launch refuses - a silently
# under-equipped seat is the failure mode this prevents.
SKILLS_LIST="${SKILLS:-$AGENT_DEF_SKILLS}"
if [ -n "$SKILLS_LIST" ]; then
mkdir -p "$MOSAIC_DEV_DIR/skills-enabled"
RESOLVED=""
OLDIFS=$IFS; IFS=','
for s in $SKILLS_LIST; do
case "$s" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid skill name: '$s'" >&2; exit 2;; esac
[ -d "$MOSAIC_DEV_DIR/skills-enabled/$s" ] || { echo "agent: skill '$s' is declared but not enabled (scripts/skill.sh activate $s)" >&2; exit 1; }
RESOLVED="${RESOLVED:+$RESOLVED,}/var/lib/mosaic/skills-enabled/$s"
done
IFS=$OLDIFS
export MOSAIC_SKILLS="$RESOLVED"
fi
if [ -n "$MISSION" ]; then
[ -r "$MISSION" ] || { echo "agent: mission file not readable: $MISSION" >&2; exit 4; }
mkdir -p "$MOSAIC_DEV_DIR/agent-missions"
cp "$MISSION" "$MOSAIC_DEV_DIR/agent-missions/$NAME.json"
export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json"
fi
# Workspace (M13): defaults to a persistent per-agent workspace
# (workspaces/<agent>) so the agent has a real, host-visible home instead
# of the container's neutral /workspace. Override with --workspace <ws>.
[ -n "$WORKSPACE" ] || WORKSPACE="$NAME"
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)"
echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit"
# No -T: the TTY is the point. Ctrl+C twice or /quit exits.
exec docker compose run --rm mosaic-agent