feat(tess): add configurable pi interaction service
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
Jarvis
2026-07-12 21:37:53 -05:00
parent 86a50138a9
commit c58e86e2b9
20 changed files with 550 additions and 7 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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" }]
},

View File

@@ -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

View File

@@ -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@<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
```
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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.

View File

@@ -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<void>
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<void>
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<RawFleetRoster['agents']>[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<RawFleetRoster['agents']>[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;
}

View File

@@ -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);

View File

@@ -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<InteractionServiceProfile> = {},
): Promise<InteractionServiceProfile> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({
code: 'invalid_profile',
} satisfies Partial<InteractionServiceProfileError>);
await expect(
readInteractionServiceProfile(profilePath, { reasoning: 'medium' }),
).rejects.toMatchObject({
code: 'invalid_profile',
} satisfies Partial<InteractionServiceProfileError>);
});
});

View File

@@ -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 {