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/<name>/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.
This commit is contained in:
@@ -286,12 +286,24 @@ _build_runtime_bin_prefix() {
|
|||||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_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
|
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() {
|
_ensure_claude_workdir_trusted() {
|
||||||
local workdir="$1"
|
local workdir="$1"
|
||||||
|
local claude_json="$2"
|
||||||
local resolved
|
local resolved
|
||||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
if [ -z "$claude_json" ]; then
|
||||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
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
|
command -v python3 >/dev/null 2>&1 || return 1
|
||||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||||
import json, os, sys, tempfile
|
import json, os, sys, tempfile
|
||||||
@@ -325,11 +337,23 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
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
|
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||||
fi
|
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_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||||
|
|
||||||
|
|||||||
@@ -409,4 +409,23 @@ if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
|||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
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'
|
echo 'ok - start-agent-session generated environment boundary'
|
||||||
|
|||||||
@@ -691,11 +691,42 @@ describe('fleet launch command outcomes', () => {
|
|||||||
SEAT_FLAG: 'yes',
|
SEAT_FLAG: 'yes',
|
||||||
},
|
},
|
||||||
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
// The bundle is reached by environment, so nothing is planted at the seat path.
|
// The bundle is reached by environment, so nothing is planted at the seat path.
|
||||||
expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false);
|
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', () => {
|
it('sets a non-zero exit code and never invokes the launcher', () => {
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
|
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
|
||||||
const program = new Command().exitOverride();
|
const program = new Command().exitOverride();
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export interface FleetLaunchCommandDeps {
|
|||||||
args: string[],
|
args: string[],
|
||||||
declaredEnv: Readonly<Record<string, string>>,
|
declaredEnv: Readonly<Record<string, string>>,
|
||||||
context: FleetHarnessContext,
|
context: FleetHarnessContext,
|
||||||
|
dangerous: boolean,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +673,10 @@ function buildArgv(
|
|||||||
passthrough: string[],
|
passthrough: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const argv: string[] = [profile.harness];
|
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') {
|
if (profile.harness === 'pi') {
|
||||||
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
|
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
|
||||||
}
|
}
|
||||||
@@ -963,33 +967,43 @@ export function registerFleetLaunchCommand(
|
|||||||
.command('launch <name>')
|
.command('launch <name>')
|
||||||
.description('Compose and launch one per-agent harness home')
|
.description('Compose and launch one per-agent harness home')
|
||||||
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
|
.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)
|
.allowUnknownOption(true)
|
||||||
.allowExcessArguments(true)
|
.allowExcessArguments(true)
|
||||||
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
|
.action(
|
||||||
try {
|
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
|
||||||
const userHome = deps.userHome ?? defaultFleetDataHome();
|
try {
|
||||||
const passthrough = command.args.slice(1);
|
const userHome = deps.userHome ?? defaultFleetDataHome();
|
||||||
const plan = resolveFleetLaunchComposition(
|
const passthrough = command.args.slice(1);
|
||||||
name,
|
const plan = resolveFleetLaunchComposition(
|
||||||
{ systemHome: systemHomeFor(), userHome },
|
name,
|
||||||
passthrough,
|
{ systemHome: systemHomeFor(), userHome },
|
||||||
);
|
passthrough,
|
||||||
if (opts.dryRun === true) {
|
);
|
||||||
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
|
if (opts.dryRun === true) {
|
||||||
return;
|
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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1207,8 +1207,9 @@ export function launchFleetRuntime(
|
|||||||
args: string[],
|
args: string[],
|
||||||
declaredEnv: Readonly<Record<string, string>>,
|
declaredEnv: Readonly<Record<string, string>>,
|
||||||
fleet: FleetHarnessContext,
|
fleet: FleetHarnessContext,
|
||||||
|
dangerous = false,
|
||||||
): never {
|
): 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. */
|
/** Bounded production-path test seam; all preflight and composition remain real. */
|
||||||
|
|||||||
Reference in New Issue
Block a user