From c1a42cdb813756621525dccb180de3067ce8edc4 Mon Sep 17 00:00:00 2001 From: terra Date: Fri, 14 Aug 2026 18:35:11 -0500 Subject: [PATCH] fleet: start a roster pane through its seat when one is scaffolded The roster lane and the harness-homes lane did not touch. start-agent-session.sh ran `mosaic yolo "$RUNTIME"` with HOME set to the operator's home, so every fleet seat on a host shared the operator's harness home and, for Claude, the operator's own ~/.claude credentials. Nothing in framework/ called `mosaic fleet launch` at all, which meant ~/.mosaic was a directory nothing read. The pane now runs `mosaic fleet launch "$AGENT_NAME"` when a scaffolded seat exists at $PANE_HOME/.mosaic/fleet/agents//profile.json, and the historical command otherwise. Detection uses $PANE_HOME/.mosaic rather than MOSAIC_DATA_HOME because the pane environment is cleared with env -i; the composition resolves the same root from HOME, so the two cannot disagree. Additive by construction: a host with no scaffolded seats launches exactly as before, so this can land ahead of any seat being enrolled. - fleet launch gains --dangerous, threaded to launchFleetRuntime. Without it a seat launched from the roster would drop the permissions footing `mosaic yolo` gave it and prompt at a pane with nobody at it. The roster launcher asks for it explicitly so it stays visible in the process table instead of becoming a profile default. - A caller's --model replaces the profile's instead of being appended after it. The roster carries a model per seat and is the surface operators edit; emitting both flags would leave the choice to each harness's argument parser. - Claude workdir trust is written into the seat's .claude.json when the pane will run in a seat home. It previously always went to the operator's ~/.claude.json, which would leave the seat prompting on its first turn. Covers Jason's scope amendment for web1: without this seam, "multiple authentication accounts and agent pegging to auth" cannot be demonstrated on a roster-managed seat. --- .../tools/fleet/start-agent-session.sh | 32 +++++++-- .../tools/fleet/test-start-agent-session.sh | 19 ++++++ .../src/commands/fleet-launch-command.spec.ts | 31 +++++++++ .../src/commands/fleet-launch-command.ts | 68 +++++++++++-------- packages/mosaic/src/commands/launch.ts | 3 +- 5 files changed, 121 insertions(+), 32 deletions(-) diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 60e73d78..f833ff36 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -286,12 +286,24 @@ _build_runtime_bin_prefix() { MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin +# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings +# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes +# ~/.mosaic real for a roster-started pane instead of a directory nothing reads. +# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below, +# so `mosaic fleet launch` resolves the same root from HOME and the two agree. +FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME" +FLEET_SEAT=0 +[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1 + _ensure_claude_workdir_trusted() { local workdir="$1" + local claude_json="$2" local resolved resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" - local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" - claude_json="${claude_json:-$HOME/.claude.json}" + if [ -z "$claude_json" ]; then + claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" + claude_json="${claude_json:-$HOME/.claude.json}" + fi command -v python3 >/dev/null 2>&1 || return 1 MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile @@ -325,11 +337,23 @@ PY } if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then - _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ + # Trust belongs to the home the seat will actually run in. Writing it to the + # operator's ~/.claude.json would leave the seat prompting on its first turn. + SEAT_CLAUDE_JSON="" + if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then + SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json" + fi + _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \ echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 fi -LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +if [ "$FLEET_SEAT" = 1 ]; then + # --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it; + # the composition, not the roster, decides harness home, bundle and settings. + LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous) +else + LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +fi if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi 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 5d1c27d6..3efd0dc9 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -409,4 +409,23 @@ if echo "$stop_args" | grep -qF 'ambient-socket'; then fail "exact stop trusted an ambient socket" fi +# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches +# through the composition instead of the operator's own home. --dangerous keeps the +# seat on the permissions footing `mosaic yolo` gave it. +: > "$TMUX_CALLS" +HOME_SEAT="$ROOT/seat" +write_generated "$HOME_SEAT" "coder-seat" +mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat" +printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \ + > "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json" +run_start "$HOME_SEAT" "coder-seat" +seat_args=$(tr '\0' '\n' < "$TMUX_CALLS") +echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch" +echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch" +echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat" +echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions" +if echo "$seat_args" | grep -qxF 'yolo'; then + fail "scaffolded seat still launched through mosaic yolo" +fi + echo 'ok - start-agent-session generated environment boundary' diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index aff36176..6048f7b3 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -691,11 +691,42 @@ describe('fleet launch command outcomes', () => { SEAT_FLAG: 'yes', }, { agentDir: fx.agentDir, mosaicHome: fx.systemHome }, + false, ); // The bundle is reached by environment, so nothing is planted at the seat path. expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false); }); + it('asks for dangerous permissions only when the caller does', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const program = new Command().exitOverride(); + const fleet = program.command('fleet'); + const launcher = vi.fn(); + registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher }); + + program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dangerous']); + + expect(launcher).toHaveBeenCalledWith('claude', [], expect.anything(), expect.anything(), true); + }); + + it('lets a caller-supplied --model replace the profile model instead of duplicating it', () => { + const fx = fixture({ schema: 1, harness: 'claude', model: 'opus' }); + const program = new Command().exitOverride(); + const fleet = program.command('fleet'); + const launcher = vi.fn(); + registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher }); + + program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--model', 'sonnet']); + + expect(launcher).toHaveBeenCalledWith( + 'claude', + ['--model', 'sonnet'], + expect.anything(), + expect.anything(), + false, + ); + }); + it('sets a non-zero exit code and never invokes the launcher', () => { const fx = fixture({ schema: 1, harness: 'claude', unknown: true }); const program = new Command().exitOverride(); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 7b308a9a..29e663be 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -149,6 +149,7 @@ export interface FleetLaunchCommandDeps { args: string[], declaredEnv: Readonly>, context: FleetHarnessContext, + dangerous: boolean, ) => void; } @@ -672,7 +673,10 @@ function buildArgv( passthrough: string[], ): string[] { const argv: string[] = [profile.harness]; - if (profile.model) argv.push('--model', profile.model); + // A caller-supplied --model replaces the profile's rather than being appended after + // it. The fleet roster carries a model per seat and is the surface operators edit, so + // it has to win; emitting both flags would leave that to each harness's arg parser. + if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model); if (profile.harness === 'pi') { for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill)); } @@ -963,33 +967,43 @@ export function registerFleetLaunchCommand( .command('launch ') .description('Compose and launch one per-agent harness home') .option('--dry-run', 'Print the fully resolved composition without writing or launching') + .option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does') .allowUnknownOption(true) .allowExcessArguments(true) - .action((name: string, opts: { dryRun?: boolean }, command: Command): void => { - try { - const userHome = deps.userHome ?? defaultFleetDataHome(); - const passthrough = command.args.slice(1); - const plan = resolveFleetLaunchComposition( - name, - { systemHome: systemHomeFor(), userHome }, - passthrough, - ); - if (opts.dryRun === true) { - process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`); - return; + .action( + (name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => { + try { + const userHome = deps.userHome ?? defaultFleetDataHome(); + const passthrough = command.args.slice(1); + const plan = resolveFleetLaunchComposition( + name, + { systemHome: systemHomeFor(), userHome }, + passthrough, + ); + if (opts.dryRun === true) { + process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`); + return; + } + applyFleetLaunchComposition(plan); + console.log(`[mosaic] bundle: ${plan.bundle.display}`); + const launcher = deps.launcher ?? launchFleetRuntime; + // Dangerous mode is the caller's to ask for, not the seat's to assume. An + // unattended tmux seat needs it -- a permission prompt with nobody at the pane + // is a hung agent -- so the roster launcher passes the flag explicitly and it + // stays visible in the process table rather than hiding in a profile default. + launcher( + plan.profile.harness, + plan.argv.slice(1), + plan.env, + { agentDir: plan.agentDir, mosaicHome: plan.systemHome }, + opts.dangerous === true, + ); + } catch (error: unknown) { + process.exitCode = 1; + const code = error instanceof FleetLaunchError ? `${error.code}: ` : ''; + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`); } - applyFleetLaunchComposition(plan); - console.log(`[mosaic] bundle: ${plan.bundle.display}`); - const launcher = deps.launcher ?? launchFleetRuntime; - launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { - agentDir: plan.agentDir, - mosaicHome: plan.systemHome, - }); - } catch (error: unknown) { - process.exitCode = 1; - const code = error instanceof FleetLaunchError ? `${error.code}: ` : ''; - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`); - } - }); + }, + ); } diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 499f6084..b5ccb92d 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -1207,8 +1207,9 @@ export function launchFleetRuntime( args: string[], declaredEnv: Readonly>, fleet: FleetHarnessContext, + dangerous = false, ): never { - return launchRuntime(runtime, args, false, { fleet, declaredEnv }); + return launchRuntime(runtime, args, dangerous, { fleet, declaredEnv }); } /** Bounded production-path test seam; all preflight and composition remain real. */