scripts/agent.sh --host-dev delegates to scripts/agent-host-dev.sh; agents/darkwing/launch.sh provides the native development TUI (context, skills, coding tools, /goal). Root SOUL.md is the M14-era default-collaborator persona captured by the ACT-1 context work. Launcher regression checks pass (test-darkwing-launch.mjs).
208 lines
10 KiB
Bash
Executable File
208 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Launch an interactive Mosaic agent (container by default).
|
|
#
|
|
# Usage:
|
|
# scripts/agent.sh <name> [--mission <file>] [--workspace <ws>]
|
|
# [--session <name>] [--tools <comma,list>] [--auth <account>]
|
|
# scripts/agent.sh --host-dev <name> [--fresh] [--check]
|
|
# [--soul <file>] [--constitution <file>] [--user <file>]
|
|
#
|
|
# 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")/.."
|
|
# Host development is an explicit leading mode, never a fallback from a
|
|
# container/config/policy refusal. Its helper owns its distinct native setup.
|
|
if [ "${1:-}" = "--host-dev" ]; then
|
|
shift
|
|
exec "$PWD/scripts/agent-host-dev.sh" "$@"
|
|
fi
|
|
# shellcheck source=common.sh
|
|
source scripts/common.sh
|
|
|
|
NAME=""
|
|
MISSION=""
|
|
WORKSPACE=""
|
|
SESSION=""
|
|
TOOLS=""
|
|
SKILLS=""
|
|
AUTH_ACCOUNT=""
|
|
|
|
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 ;;
|
|
--auth) AUTH_ACCOUNT="${2:?}"; shift 2 ;;
|
|
--skills) SKILLS="${2:?}"; shift 2 ;;
|
|
--help|-h) sed -n '2,15p' "$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] [--auth account]" >&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
|
|
|
|
# Named auth account (M19, corrected per owner in #48): per-launch
|
|
# credential injection from MOSAIC-MANAGED storage under the data root —
|
|
# never from inside ~/.pi, which is read-only to the stack. Resolves
|
|
# <dataRoot>/auth/<account>.json and exports PI_AUTH_FILE (the compose
|
|
# read-only mount source). Missing, symlinked, or non-0600 files refuse:
|
|
# silently falling back to another credential would put one account's work
|
|
# on another's identity.
|
|
if [ -n "$AUTH_ACCOUNT" ]; then
|
|
case "$AUTH_ACCOUNT" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid auth account name" >&2; exit 4;; esac
|
|
AUTH_RESOLVED="$MOSAIC_DEV_DIR/auth/$AUTH_ACCOUNT.json"
|
|
[ -r "$AUTH_RESOLVED" ] && [ ! -L "$AUTH_RESOLVED" ] || { echo "agent: no mosaic-managed credential file for auth account '$AUTH_ACCOUNT': $AUTH_RESOLVED (see scripts/auth.sh accounts)" >&2; exit 4; }
|
|
AUTH_PERMS="$(stat -c %a "$AUTH_RESOLVED")"
|
|
[ "$AUTH_PERMS" = "600" ] || { echo "agent: auth account '$AUTH_ACCOUNT' file must be 0600 (got $AUTH_PERMS): $AUTH_RESOLVED" >&2; exit 4; }
|
|
export PI_AUTH_FILE="$AUTH_RESOLVED"
|
|
echo "agent: auth: account '$AUTH_ACCOUNT' -> $AUTH_RESOLVED" >&2
|
|
fi
|
|
|
|
# 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
|