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.
181 lines
8.4 KiB
Bash
Executable File
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
|