From c58e86e2b944b76461b6f8cbfff07a0c9d8e5bf2 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 21:37:53 -0500 Subject: [PATCH] feat(tess): add configurable pi interaction service --- docs/scratchpads/tess-m2-001-pi-service.md | 40 +++++ docs/tess/ARCHITECTURE.md | 2 + packages/mosaic/framework/fleet/README.md | 15 ++ .../fleet/examples/operator-interaction.yaml | 19 +++ .../fleet/roles/operator-interaction.md | 11 ++ .../mosaic/framework/fleet/roster.schema.json | 12 ++ .../fleet/services/operator-interaction.yaml | 5 + .../mosaic/framework/systemd/user/README.md | 16 +- .../user/mosaic-interaction-agent@.service | 17 ++ .../systemd/user/test-fleet-units.sh | 7 +- .../print-interaction-effective-policy.sh | 18 ++ .../tools/fleet/start-agent-session.sh | 16 +- .../tools/fleet/start-interaction-service.sh | 19 +++ .../tools/fleet/test-start-agent-session.sh | 3 + .../src/commands/compose-contract.spec.ts | 13 ++ packages/mosaic/src/commands/fleet.ts | 58 ++++++- packages/mosaic/src/commands/launch.ts | 14 ++ .../src/fleet/interaction-service-profile.ts | 110 ++++++++++++ .../src/fleet/tess-service-profile.test.ts | 161 ++++++++++++++++++ packages/mosaic/src/index.ts | 1 + 20 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 docs/scratchpads/tess-m2-001-pi-service.md create mode 100644 packages/mosaic/framework/fleet/examples/operator-interaction.yaml create mode 100644 packages/mosaic/framework/fleet/roles/operator-interaction.md create mode 100644 packages/mosaic/framework/fleet/services/operator-interaction.yaml create mode 100644 packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service create mode 100755 packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh create mode 100755 packages/mosaic/framework/tools/fleet/start-interaction-service.sh create mode 100644 packages/mosaic/src/fleet/interaction-service-profile.ts create mode 100644 packages/mosaic/src/fleet/tess-service-profile.test.ts diff --git a/docs/scratchpads/tess-m2-001-pi-service.md b/docs/scratchpads/tess-m2-001-pi-service.md new file mode 100644 index 0000000..16aaffd --- /dev/null +++ b/docs/scratchpads/tess-m2-001-pi-service.md @@ -0,0 +1,40 @@ +# TESS-M2-001 — Pi Interaction Service + +## Scope + +- Add a generic rostered/systemd Pi operator-interaction service in + `packages/mosaic/framework`. +- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the + `operator-interaction` tool policy. +- Keep identity as provisioning data; the product name appears only in the + committed example roster. + +## Security and Configuration Invariants + +1. The chosen display/roster name is supplied as data and must exactly match the + generic systemd instance. +2. The service fails before launch if runtime, model, reasoning, or tool policy + differs from the pinned policy. +3. Effective-policy output includes only name, runtime, model, reasoning, and + tool policy; it does not inspect or output credential variables. +4. The default example is replaceable without a source change; the TDD suite + provisions `Nova` from the same profile. + +## Evidence + +- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path, + roster parser/env serialization, effective-policy output, fail-fast drift + rejection, and absence of the product name from generic source/profile. +- `test-fleet-units.sh` validates the generic interaction systemd unit requires + per-agent config and invokes fail-fast startup validation. +- `test-start-agent-session.sh` proves the tool-policy value is exported into + the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit + runtime contract block. +- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`, + `pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed. +- Independent Codex code and security reviews passed with no remaining findings. + +## Delivery Notes + +- Branch starts from fresh `origin/main` at `86a50138`. +- PR targets `main` and references issue `#708`. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index bd8e647..4c03162 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -81,3 +81,5 @@ The provider advertises list, tree, read-only attach, send, and terminate. List/ ## Deployment Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material. + +The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields. diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index b1480fd..36cfb1e 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -15,6 +15,21 @@ default tmux server. - `examples/minimal.yaml` starts one local canary slot. - `examples/local-canary.yaml` starts a small generic dogfood fleet. +- `examples/operator-interaction.yaml` is an example Pi operator-interaction + service; replace its example agent name before provisioning. + +## Operator interaction service + +`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model, +high reasoning, and the `operator-interaction` tool policy. The agent identity +is provisioning data: choose a roster name, generate its per-agent environment +file, then start the matching generic systemd instance. The service fails before +launch if the configured identity does not match the instance or any pinned +policy field drifts. + +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. Initialize a roster: diff --git a/packages/mosaic/framework/fleet/examples/operator-interaction.yaml b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml new file mode 100644 index 0000000..1f9a4f9 --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml @@ -0,0 +1,19 @@ +# Example instance only. Replace `Tess` with the chosen provisioned identity. +version: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src +runtimes: + pi: + reset_command: /new +agents: + - name: Tess + runtime: pi + class: operator-interaction + model_hint: openai/gpt-5.6-sol + reasoning_level: high + tool_policy: operator-interaction + persistent_persona: true diff --git a/packages/mosaic/framework/fleet/roles/operator-interaction.md b/packages/mosaic/framework/fleet/roles/operator-interaction.md new file mode 100644 index 0000000..48f2490 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/operator-interaction.md @@ -0,0 +1,11 @@ +# Operator Interaction — fleet role definition + +The **operator-interaction** role is the authorized human interaction plane for +Mosaic. It presents runtime and fleet state, mediates approved actions, and +hands coding or general orchestration work to Mos. + +## Boundaries + +- It does not claim Mos-owned coding or general orchestration work. +- It exposes only the configured, observable tool policy. +- It does not receive or surface credentials in its effective policy. diff --git a/packages/mosaic/framework/fleet/roster.schema.json b/packages/mosaic/framework/fleet/roster.schema.json index 4327075..85b862c 100644 --- a/packages/mosaic/framework/fleet/roster.schema.json +++ b/packages/mosaic/framework/fleet/roster.schema.json @@ -105,6 +105,18 @@ "modelHint": { "type": "string" }, + "reasoning_level": { + "type": "string" + }, + "reasoningLevel": { + "type": "string" + }, + "tool_policy": { + "type": "string" + }, + "toolPolicy": { + "type": "string" + }, "persistent_persona": { "oneOf": [{ "type": "boolean" }, { "type": "string" }] }, diff --git a/packages/mosaic/framework/fleet/services/operator-interaction.yaml b/packages/mosaic/framework/fleet/services/operator-interaction.yaml new file mode 100644 index 0000000..1d016fd --- /dev/null +++ b/packages/mosaic/framework/fleet/services/operator-interaction.yaml @@ -0,0 +1,5 @@ +# Generic service policy. Provisioning supplies the agent name as data. +runtime: pi +model: openai/gpt-5.6-sol +reasoning: high +tool_policy: operator-interaction diff --git a/packages/mosaic/framework/systemd/user/README.md b/packages/mosaic/framework/systemd/user/README.md index f1e0b8a..1811ead 100644 --- a/packages/mosaic/framework/systemd/user/README.md +++ b/packages/mosaic/framework/systemd/user/README.md @@ -12,6 +12,8 @@ exact-match session. - `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server. - `mosaic-agent@.service` — user-mode template for one reusable agent session. +- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template + that fails fast when its pinned runtime policy is incomplete or changed. - `test-fleet-units.sh` — validates unit syntax and required relationships. The agent template calls: @@ -45,12 +47,22 @@ MOSAIC_AGENT_WORKDIR=$HOME/src/your-project ```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/start-agent-session.sh ~/.config/mosaic/tools/fleet/ -chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh +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 + +# For an operator-interaction service, the roster/env identity selects the +# generic unit instance; no service source is renamed for an instance. +systemctl --user start mosaic-interaction-agent@.service +MOSAIC_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 ``` Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant diff --git a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service new file mode 100644 index 0000000..346dbd8 --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service @@ -0,0 +1,17 @@ +[Unit] +Description=Mosaic operator interaction agent %i +Documentation=https://git.mosaicstack.dev/mosaicstack/stack +Requires=mosaic-tmux-holder.service +After=mosaic-tmux-holder.service +PartOf=mosaic-tmux-holder.service + +[Service] +Type=oneshot +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' + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index de5d7c2..041a325 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -4,6 +4,7 @@ set -euo pipefail 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" fail() { echo "FAIL: $*" >&2 @@ -12,6 +13,7 @@ fail() { [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$AGENT" ] || fail "missing mosaic-agent@.service" +[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service" grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart" grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" @@ -19,9 +21,12 @@ grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit" 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" +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" if command -v systemd-analyze >/dev/null 2>&1; then - systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { + systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { cat /tmp/mosaic-fleet-systemd-verify.log >&2 fail "systemd-analyze verify failed" } diff --git a/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh new file mode 100755 index 0000000..9f31e0a --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${MOSAIC_AGENT_NAME:-} +RUNTIME=${MOSAIC_AGENT_RUNTIME:-} +MODEL=${MOSAIC_AGENT_MODEL:-} +REASONING=${MOSAIC_AGENT_REASONING:-} +TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-} + +[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; } +[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; } +[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; } +[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; } +[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; } +[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; } + +printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \ + "$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY" diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 7bb9731..e5c9f2c 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -8,6 +8,7 @@ AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} 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} @@ -18,6 +19,14 @@ if [ -z "$AGENT_NAME" ]; then 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 + ;; +esac + if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 @@ -41,7 +50,7 @@ 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_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}" fi # ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── @@ -124,11 +133,12 @@ AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") # 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 PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" + 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}; exec ${MOSAIC_AGENT_COMMAND}" + 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" diff --git a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh new file mode 100755 index 0000000..4efc539 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${1:-} + +fail() { + echo "ERROR: $*" >&2 + exit 64 +} + +[ -n "$AGENT_NAME" ] || fail "agent name argument is required" +[[ "$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" diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 837ef93..0e83ecc 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -105,6 +105,7 @@ 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" \ @@ -139,6 +140,8 @@ 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) diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index bc021e4..6fcef51 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -165,6 +165,19 @@ describe('composeContract — overlay composer', () => { expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract'); }); + it('injects the configured operator-interaction tool policy into the runtime contract', () => { + const previous = process.env['MOSAIC_AGENT_TOOL_POLICY']; + try { + process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction'; + const out = composeContract('pi', fixture.home); + expect(out).toContain('# Fleet Tool Policy (operator-interaction)'); + expect(out).toContain('Denied by default: coding/general orchestration claims'); + } finally { + if (previous === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY']; + else process.env['MOSAIC_AGENT_TOOL_POLICY'] = previous; + } + }); + // ── Persona contract injection (A3b) ────────────────────────────────────── // composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona // (override-aware). Save/restore the env so these tests don't leak state. diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index d287db5..293fc9b 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -87,6 +87,10 @@ interface RawFleetRoster { workingDirectory?: unknown; model_hint?: unknown; modelHint?: unknown; + reasoning_level?: unknown; + reasoningLevel?: unknown; + tool_policy?: unknown; + toolPolicy?: unknown; persistent_persona?: unknown; persistentPersona?: unknown; reset_between_tasks?: unknown; @@ -102,6 +106,8 @@ export interface FleetAgent { className: string; workingDirectory?: string; modelHint?: string; + reasoningLevel?: string; + toolPolicy?: string; persistentPersona?: boolean | string; resetBetweenTasks?: boolean; kickstartTemplate?: string; @@ -510,6 +516,12 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string // the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on // openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint. `MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`, + ...(agent.reasoningLevel !== undefined + ? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`] + : []), + ...(agent.toolPolicy !== undefined + ? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`] + : []), `MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`, `MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`, '', @@ -2316,13 +2328,35 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise await mkdir(activePaths.agentEnvDir, { recursive: true }); const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh'); + const startInteractionServicePath = join( + activePaths.fleetToolsDir, + 'start-interaction-service.sh', + ); + const printInteractionPolicyPath = join( + activePaths.fleetToolsDir, + 'print-interaction-effective-policy.sh', + ); const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh'); const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh'); - const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath]; + const executableToolPaths = [ + startAgentSessionPath, + startInteractionServicePath, + printInteractionPolicyPath, + sendMessagePath, + agentSendPath, + ]; await copyFile( join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'), startAgentSessionPath, ); + await copyFile( + join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'), + startInteractionServicePath, + ); + await copyFile( + join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'), + printInteractionPolicyPath, + ); await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath); await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath); for (const toolPath of executableToolPaths) { @@ -2336,6 +2370,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'), join(activePaths.systemdUserDir, 'mosaic-agent@.service'), ); + await copyFile( + join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'), + join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'), + ); for (const agent of roster.agents) { const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`); @@ -2462,6 +2500,10 @@ function normalizeAgent(raw: NonNullable[number]): Fle 'workingDirectory', 'model_hint', 'modelHint', + 'reasoning_level', + 'reasoningLevel', + 'tool_policy', + 'toolPolicy', 'persistent_persona', 'persistentPersona', 'reset_between_tasks', @@ -2493,6 +2535,14 @@ function normalizeAgent(raw: NonNullable[number]): Fle raw.model_hint ?? raw.modelHint, `Fleet roster agent "${name}" model_hint`, ), + reasoningLevel: optionalString( + raw.reasoning_level ?? raw.reasoningLevel, + `Fleet roster agent "${name}" reasoning_level`, + ), + toolPolicy: optionalString( + raw.tool_policy ?? raw.toolPolicy, + `Fleet roster agent "${name}" tool_policy`, + ), persistentPersona: optionalBooleanOrString( raw.persistent_persona ?? raw.persistentPersona, `Fleet roster agent "${name}" persistent_persona`, @@ -2783,6 +2833,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string { if (agent.modelHint !== undefined) { raw['model_hint'] = agent.modelHint; } + if (agent.reasoningLevel !== undefined) { + raw['reasoning_level'] = agent.reasoningLevel; + } + if (agent.toolPolicy !== undefined) { + raw['tool_policy'] = agent.toolPolicy; + } if (agent.persistentPersona !== undefined) { raw['persistent_persona'] = agent.persistentPersona; } diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index fabff2a..4d96f9a 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -395,6 +395,9 @@ For required push/merge/issue-close/release actions, execute without routine con const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']); if (persona) parts.push('\n\n' + persona); + const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']); + if (toolPolicy) parts.push('\n\n' + toolPolicy); + // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set // and present in the roster), inject a comms cheat-sheet + peer roster so it // knows how to reach the orchestrator and its peers from its first turn. @@ -404,6 +407,17 @@ For required push/merge/issue-close/release actions, execute without routine con return parts.join('\n'); } +function readFleetToolPolicyBlock(policy: string | undefined): string { + if (policy !== 'operator-interaction') return ''; + return [ + '# Fleet Tool Policy (operator-interaction)', + '', + 'Permitted: authorized conversation, status, retrieval, and safe diagnostics.', + 'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.', + 'Delegate Mos-owned work through the authorized handoff boundary.', + ].join('\n'); +} + /** @deprecated internal alias — use composeContract. Retained for call-site clarity. */ function buildRuntimePrompt(runtime: RuntimeName): string { return composeContract(runtime); diff --git a/packages/mosaic/src/fleet/interaction-service-profile.ts b/packages/mosaic/src/fleet/interaction-service-profile.ts new file mode 100644 index 0000000..d22fccb --- /dev/null +++ b/packages/mosaic/src/fleet/interaction-service-profile.ts @@ -0,0 +1,110 @@ +import { readFile } from 'node:fs/promises'; +import YAML from 'yaml'; +import type { FleetAgent } from '../commands/fleet.js'; + +const REQUIRED_RUNTIME = 'pi'; +const REQUIRED_MODEL = 'openai/gpt-5.6-sol'; +const REQUIRED_REASONING = 'high'; +const REQUIRED_TOOL_POLICY = 'operator-interaction'; +const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; + +export interface InteractionServiceProfile { + runtime: string; + model: string; + reasoning: string; + toolPolicy: string; +} + +export interface EffectiveInteractionPolicy { + agentName: string; + runtime: string; + model: string; + reasoning: string; + toolPolicy: string; +} + +export class InteractionServiceProfileError extends Error { + constructor( + readonly code: 'invalid_profile' | 'invalid_request', + message: string, + ) { + super(message); + this.name = InteractionServiceProfileError.name; + } +} + +export async function readInteractionServiceProfile( + path: string, + overrides: Partial = {}, +): Promise { + const parsed: unknown = YAML.parse(await readFile(path, 'utf8')); + if (!isRecord(parsed)) { + throw new InteractionServiceProfileError( + 'invalid_profile', + 'Service profile must be an object', + ); + } + const profile: InteractionServiceProfile = { + runtime: valueOrUndefined(overrides.runtime, parsed['runtime']), + model: valueOrUndefined(overrides.model, parsed['model']), + reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']), + toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']), + }; + assertPinnedPolicy(profile); + return profile; +} + +export function provisionInteractionService( + profile: InteractionServiceProfile, + input: { agentName: string }, +): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } { + assertPinnedPolicy(profile); + if (!AGENT_NAME_PATTERN.test(input.agentName)) { + throw new InteractionServiceProfileError( + 'invalid_request', + 'Agent name must contain only letters, numbers, dots, underscores, or hyphens', + ); + } + const effectivePolicy: EffectiveInteractionPolicy = { + agentName: input.agentName, + runtime: profile.runtime, + model: profile.model, + reasoning: profile.reasoning, + toolPolicy: profile.toolPolicy, + }; + return { + rosterAgent: { + name: input.agentName, + runtime: profile.runtime, + className: profile.toolPolicy, + modelHint: profile.model, + reasoningLevel: profile.reasoning, + toolPolicy: profile.toolPolicy, + persistentPersona: true, + }, + effectivePolicy, + }; +} + +function assertPinnedPolicy(profile: InteractionServiceProfile): void { + const invalid = + profile.runtime !== REQUIRED_RUNTIME || + profile.model !== REQUIRED_MODEL || + profile.reasoning !== REQUIRED_REASONING || + profile.toolPolicy !== REQUIRED_TOOL_POLICY; + if (invalid) { + throw new InteractionServiceProfileError( + 'invalid_profile', + `Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`, + ); + } +} + +function valueOrUndefined(override: string | undefined, value: unknown): string { + if (override !== undefined) return override; + return typeof value === 'string' ? value : ''; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/mosaic/src/fleet/tess-service-profile.test.ts b/packages/mosaic/src/fleet/tess-service-profile.test.ts new file mode 100644 index 0000000..5c651a5 --- /dev/null +++ b/packages/mosaic/src/fleet/tess-service-profile.test.ts @@ -0,0 +1,161 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js'; +import { + provisionInteractionService, + readInteractionServiceProfile, +} from './interaction-service-profile.js'; +import type { InteractionServiceProfileError } from './interaction-service-profile.js'; + +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); +const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml'); +const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml'); +const policyScript = join( + frameworkFleet, + '..', + 'tools', + 'fleet', + 'print-interaction-effective-policy.sh', +); +const interactionStartScript = join( + frameworkFleet, + '..', + 'tools', + 'fleet', + 'start-interaction-service.sh', +); +const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh'); +const execFileAsync = promisify(execFile); + +describe('operator interaction service profile', (): void => { + it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise => { + const profile = await readInteractionServiceProfile(profilePath); + const provisioned = provisionInteractionService(profile, { agentName: 'Nova' }); + + expect(provisioned.rosterAgent).toEqual({ + name: 'Nova', + runtime: 'pi', + className: 'operator-interaction', + modelHint: 'openai/gpt-5.6-sol', + reasoningLevel: 'high', + toolPolicy: 'operator-interaction', + persistentPersona: true, + }); + expect(provisioned.effectivePolicy).toEqual({ + agentName: 'Nova', + runtime: 'pi', + model: 'openai/gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'operator-interaction', + }); + expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch( + /token|secret|credential|api.?key/i, + ); + }); + + it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-')); + const rosterPath = join(directory, 'roster.yaml'); + try { + const example = await readFile(examplePath, 'utf8'); + await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8'); + const roster = await loadFleetRoster(rosterPath); + const agent = roster.agents[0]!; + + expect(agent.name).toBe('Nova'); + expect(agent.reasoningLevel).toBe('high'); + expect(agent.toolPolicy).toBe('operator-interaction'); + expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova'); + expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high'); + + const { stdout } = await execFileAsync(policyScript, [], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: agent.name, + MOSAIC_AGENT_RUNTIME: agent.runtime, + MOSAIC_AGENT_MODEL: agent.modelHint, + MOSAIC_AGENT_REASONING: agent.reasoningLevel, + MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy, + }, + }); + expect(JSON.parse(stdout)).toEqual({ + agentName: 'Nova', + runtime: 'pi', + model: 'openai/gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'operator-interaction', + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('fails before launch when service environment drifts from the pinned policy', async (): Promise => { + await expect( + execFileAsync(interactionStartScript, ['Nova'], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: 'Nova', + MOSAIC_AGENT_RUNTIME: 'pi', + MOSAIC_AGENT_MODEL: 'other/model', + MOSAIC_AGENT_REASONING: 'high', + MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction', + }, + }), + ).rejects.toMatchObject({ code: 64 }); + }); + + it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise => { + await expect( + execFileAsync(policyScript, [], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: 'Nova"bad', + MOSAIC_AGENT_RUNTIME: 'pi', + MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol', + MOSAIC_AGENT_REASONING: 'high', + MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction', + }, + }), + ).rejects.toMatchObject({ code: 64 }); + await expect( + execFileAsync(agentStartScript, ['Nova'], { + env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' }, + }), + ).rejects.toMatchObject({ code: 64 }); + }); + + it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise => { + const [profile, source, example] = await Promise.all([ + readFile(profilePath, 'utf8'), + readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'), + readFile(examplePath, 'utf8'), + ]); + + expect(profile).not.toMatch(/tess/i); + expect(source).not.toMatch(/tess/i); + expect(example).toMatch(/name: Tess/); + }); + + it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise => { + await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({ + code: 'invalid_profile', + } satisfies Partial); + await expect( + readInteractionServiceProfile(profilePath, { reasoning: 'medium' }), + ).rejects.toMatchObject({ + code: 'invalid_profile', + } satisfies Partial); + }); +}); diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index cc881e5..4ff4162 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -1,5 +1,6 @@ export const VERSION = '0.0.0'; +export * from './fleet/interaction-service-profile.js'; export * from './fleet/tmux-runtime-transport.js'; export {