From a80bae950d85170b607c6dc57232c3db4ce8af5e Mon Sep 17 00:00:00 2001 From: Jason Woltje <2+jason.woltje@noreply.git.mosaicstack.dev> Date: Tue, 18 Aug 2026 03:23:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(fleet):=20brain-home=20split=20=E2=80=94?= =?UTF-8?q?=20fleet=20state=20under=20~/.mosaic,=20templates=20stay=20conf?= =?UTF-8?q?ig-home=20(#1298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jason Woltje <2+jason.woltje@noreply.git.mosaicstack.dev> --- packages/mosaic/framework/fleet/README.md | 27 +++++ .../tools/fleet/start-agent-session.sh | 20 +++ .../tools/fleet/test-start-agent-session.sh | 48 ++++++++ .../src/commands/fleet-agent-crud-command.ts | 5 +- .../src/commands/fleet-migration-command.ts | 5 +- .../mosaic/src/commands/fleet-personas.ts | 8 +- .../mosaic/src/commands/fleet-profiles.ts | 6 +- .../src/commands/fleet-regen-command.ts | 3 +- packages/mosaic/src/commands/fleet.ts | 3 +- packages/mosaic/src/commands/launch.spec.ts | 87 +++++++++++++ packages/mosaic/src/commands/launch.ts | 53 +++++++- packages/mosaic/src/fleet/brain-home.spec.ts | 114 ++++++++++++++++++ packages/mosaic/src/fleet/brain-home.ts | 76 ++++++++++++ packages/mosaic/src/fleet/fleet-reconciler.ts | 3 +- .../src/fleet/generated-env-boundary.spec.ts | 46 +++++++ .../src/fleet/generated-env-boundary.ts | 15 ++- 16 files changed, 498 insertions(+), 21 deletions(-) create mode 100644 packages/mosaic/src/fleet/brain-home.spec.ts create mode 100644 packages/mosaic/src/fleet/brain-home.ts diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index df42327f..b56c8451 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -12,6 +12,33 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the default tmux server. The roster is the desired-state authority; generated environment files are rebuildable projections, never a second source of configuration. +## Brain-home split (fleet state vs framework templates) + +When a mosaic-brain clone is present, fleet **state** resolves from the brain +home while framework templates and dispatch state stay in the config home +(three-tree model, canon `docs/STRUCTURE-CANON.md` §2): + +| Path | Without brain (legacy) | With brain | +| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ | +| `fleet/agents/.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` | +| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` | +| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` | +| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) | + +Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in +`tools/fleet/start-agent-session.sh`): + +1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins. +2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default + `~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom + `--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping + them hermetic. +3. Otherwise the config home (legacy single-tree behavior). + +Seat env dirs under a brain are subject to the same privacy boundary (0700 +dirs, 0600 files); `.env.generated` files are structure-valuable and tracked +in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private. + ## Examples - `examples/minimal.yaml` starts one local canary slot. diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 6faffa38..f07470be 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -80,6 +80,26 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME" FLEET_DIR="$MOSAIC_HOME/fleet" AGENT_ENV_DIR="$FLEET_DIR/agents" + +# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live +# under the brain home's fleet/agents when a brain is active; roster, roles +# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME. +# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts: +# 1. MOSAIC_BRAIN_HOME env (explicit, always wins) +# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND +# ~/.mosaic/fleet/agents exists +# 3. MOSAIC_HOME (legacy single-tree) +BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}" +if [ -z "$BRAIN_HOME" ]; then + BRAIN_HOME="$MOSAIC_HOME" + if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \ + && [ -d "$HOME/.mosaic/fleet/agents" ]; then + BRAIN_HOME="$HOME/.mosaic" + fi +fi +if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then + AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents" +fi assert_managed_directory "$MOSAIC_HOME" assert_managed_directory "$FLEET_DIR" assert_private_directory "$AGENT_ENV_DIR" 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 e7d8e94f..1f84e2da 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -167,6 +167,54 @@ if echo "$valid_args" | grep -qF 'bash -c'; then fail "launcher constructed a shell command payload" fi +# ── Brain-home split (canon §2) ───────────────────────────────────────── +# When MOSAIC_HOME is the default config home under $HOME and the host carries +# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config +# home still owns fleet/run (holder-owner) and remains a managed boundary. +: > "$TMUX_CALLS" +HOME_BRAIN="$ROOT/brain-home" +CONFIG_HOME="$HOME_BRAIN/.config/mosaic" +BRAIN="$HOME_BRAIN/.mosaic" +mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work" +chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \ + "$BRAIN/fleet/agents" "$HOME_BRAIN/work" +printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner" +chmod 600 "$CONFIG_HOME/fleet/run/holder-owner" +cat > "$BRAIN/fleet/agents/coder-brain.env.generated" < "$TMUX_CALLS" +HOME_NOBRAIN="$ROOT/brainless-home" +CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic" +write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy" +install_pane_binaries "$HOME_NOBRAIN" +HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ + MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \ + MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ + MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy +legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS") +echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed" + # The pane must start through an absolute clean-environment boundary. Its # runtime command remains an argv vector, but no holder/session environment # control variable can pass through the pane command. diff --git a/packages/mosaic/src/commands/fleet-agent-crud-command.ts b/packages/mosaic/src/commands/fleet-agent-crud-command.ts index b2aac15d..8085f708 100644 --- a/packages/mosaic/src/commands/fleet-agent-crud-command.ts +++ b/packages/mosaic/src/commands/fleet-agent-crud-command.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js'; import type { Command } from 'commander'; import { executeFleetAgentMutation, @@ -149,9 +150,9 @@ async function executeCommand( request, mosaicHome, rosterPath, - agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + agentEnvDir: fleetAgentEnvDir(mosaicHome), rolesDir: join(mosaicHome, 'fleet', 'roles'), - overrideDir: join(mosaicHome, 'fleet', 'roles.local'), + overrideDir: fleetRolesLocalDir(mosaicHome), dryRun: forceDryRun || opts.dryRun === true, ...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }), }); diff --git a/packages/mosaic/src/commands/fleet-migration-command.ts b/packages/mosaic/src/commands/fleet-migration-command.ts index 0c85a57e..907f16b2 100644 --- a/packages/mosaic/src/commands/fleet-migration-command.ts +++ b/packages/mosaic/src/commands/fleet-migration-command.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js'; import type { Command } from 'commander'; import { parseV1MigrationObservations, @@ -120,11 +121,11 @@ export function registerFleetMigrationCommand( observations, personaDirs: { rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'), - overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'), + overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome), }, environment: { mosaicHome, - agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + agentEnvDir: fleetAgentEnvDir(mosaicHome), }, }); printJson(preview); diff --git a/packages/mosaic/src/commands/fleet-personas.ts b/packages/mosaic/src/commands/fleet-personas.ts index c3fe3fee..3333ea07 100644 --- a/packages/mosaic/src/commands/fleet-personas.ts +++ b/packages/mosaic/src/commands/fleet-personas.ts @@ -30,19 +30,21 @@ import { lstat, readFile, readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, isAbsolute, join, sep } from 'node:path'; import type { Command } from 'commander'; +import { fleetRolesLocalDir } from '../fleet/brain-home.js'; function defaultMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); } -/** Baseline persona role contracts (reseeded on update). */ +/** Baseline persona role contracts (reseeded on update; config home — framework). */ export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string { return join(mosaicHome, 'fleet', 'roles'); } -/** PRESERVE-protected override layer (survives update; wins on merge). */ +/** PRESERVE-protected override layer (survives update; wins on merge). + * Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string { - return join(mosaicHome, 'fleet', 'roles.local'); + return fleetRolesLocalDir(mosaicHome); } /** diff --git a/packages/mosaic/src/commands/fleet-profiles.ts b/packages/mosaic/src/commands/fleet-profiles.ts index 96899d9a..aaf82e26 100644 --- a/packages/mosaic/src/commands/fleet-profiles.ts +++ b/packages/mosaic/src/commands/fleet-profiles.ts @@ -25,6 +25,7 @@ import { homedir } from 'node:os'; import { basename, join } from 'node:path'; import type { Command } from 'commander'; import YAML from 'yaml'; +import { fleetProfilesDir } from '../fleet/brain-home.js'; import { defaultOverrideDir, extractClassesFromDir, @@ -36,9 +37,10 @@ function defaultMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); } -/** Directory holding the seeded profile yaml files. */ +/** Directory holding the seeded profile yaml files — brain home when active + * (user working copies, committed), else the config home seed. */ export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string { - return join(mosaicHome, 'fleet', 'profiles'); + return fleetProfilesDir(mosaicHome); } /** Directory holding the persona role contracts. */ diff --git a/packages/mosaic/src/commands/fleet-regen-command.ts b/packages/mosaic/src/commands/fleet-regen-command.ts index 6f478b0b..007b65d6 100644 --- a/packages/mosaic/src/commands/fleet-regen-command.ts +++ b/packages/mosaic/src/commands/fleet-regen-command.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import type { Command } from 'commander'; import type { CommandRunner } from './fleet.js'; +import { fleetAgentEnvDir } from '../fleet/brain-home.js'; import { applyPreparedGeneratedAgentEnvironmentProjection, prepareGeneratedAgentEnvironmentProjection, @@ -153,7 +154,7 @@ export async function executeFleetRegen( options: FleetRegenOptions, ): Promise { const mosaicHome = defaultMosaicHome(deps); - const agentEnvDir = join(mosaicHome, 'fleet', 'agents'); + const agentEnvDir = fleetAgentEnvDir(mosaicHome); const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome); const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection; diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index b77c6e3a..2bf0bab8 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -13,6 +13,7 @@ import { import { randomUUID } from 'node:crypto'; import { homedir, hostname, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { fleetAgentEnvDir } from '../fleet/brain-home.js'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import * as readline from 'node:readline'; @@ -158,7 +159,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths fleetToolsDir: join(mosaicHome, 'tools', 'fleet'), tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'), systemdUserDir: join(homedir(), '.config', 'systemd', 'user'), - agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + agentEnvDir: fleetAgentEnvDir(mosaicHome), }; } diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index d468c9bc..2f737173 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -349,3 +349,90 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => { expect(mockExit).not.toHaveBeenCalled(); }); }); + +// ─── Seat harness homes (MOSAIC-D-002, brain-home split) ──────────────────── + +import { activeSeatDir, seatPersonaOverlay } from './launch.js'; + +describe('activeSeatDir — per-agent harness home resolution', () => { + let root: string; + const savedAgentName = process.env['MOSAIC_AGENT_NAME']; + const savedBrainHome = process.env['MOSAIC_BRAIN_HOME']; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'mosaic-seat-home-')); + delete process.env['MOSAIC_BRAIN_HOME']; + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + if (savedAgentName === undefined) { + delete process.env['MOSAIC_AGENT_NAME']; + } else { + process.env['MOSAIC_AGENT_NAME'] = savedAgentName; + } + if (savedBrainHome !== undefined) { + process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome; + } else { + delete process.env['MOSAIC_BRAIN_HOME']; + } + }); + + it('resolves the seat dir when MOSAIC_BRAIN_HOME carries the seat', () => { + const seat = join(root, 'brain', 'fleet', 'agents', 'coder0'); + mkdirSync(seat, { recursive: true }); + process.env['MOSAIC_AGENT_NAME'] = 'coder0'; + process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain'); + + expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBe(seat); + }); + + it('returns undefined without an agent name (bare launches stay shared)', () => { + delete process.env['MOSAIC_AGENT_NAME']; + expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined(); + }); + + it('returns undefined when the seat dir does not exist in the brain', () => { + process.env['MOSAIC_AGENT_NAME'] = 'ghost'; + process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain'); + mkdirSync(join(root, 'brain', 'fleet', 'agents'), { recursive: true }); + + expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined(); + }); + + it.each(['../escape', 'a/b', '.hidden-start', '', 'spaced name'])( + 'rejects unsafe agent name %j (path traversal cannot leave the seat store)', + (name: string) => { + process.env['MOSAIC_AGENT_NAME'] = name; + process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain'); + + expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined(); + }, + ); + + it('seatPersonaOverlay renders the seat SOUL.md as an overlay block', () => { + const seat = join(root, 'brain', 'fleet', 'agents', 'coder0'); + mkdirSync(seat, { recursive: true }); + writeFileSync(join(seat, 'SOUL.md'), '# coder0 — code seat persona\n\nShips tested code.\n'); + process.env['MOSAIC_AGENT_NAME'] = 'coder0'; + process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain'); + + const overlay = seatPersonaOverlay(join(root, 'config', 'mosaic')); + expect(overlay).toContain('## Seat Persona'); + expect(overlay).toContain('coder0 — code seat persona'); + }); + + it('seatPersonaOverlay is empty when the seat carries no SOUL.md', () => { + const seat = join(root, 'brain', 'fleet', 'agents', 'coder0'); + mkdirSync(seat, { recursive: true }); + process.env['MOSAIC_AGENT_NAME'] = 'coder0'; + process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain'); + + expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe(''); + }); + + it('seatPersonaOverlay is empty when no agent name is set', () => { + delete process.env['MOSAIC_AGENT_NAME']; + expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe(''); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index c4d8f3d2..5593c3fd 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -19,7 +19,7 @@ import { import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { homedir, hostname } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, resolve } from 'node:path'; import type { Command } from 'commander'; import { buildResolvedFleetCommsBlock, @@ -29,6 +29,7 @@ import { import { readRegularFileSecure } from '../fleet/secure-file.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; +import { resolveBrainHome } from '../fleet/brain-home.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; @@ -64,9 +65,46 @@ const HARNESS_HOME_ENV: Record = { opencode: 'XDG_CONFIG_HOME', }; -/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/. */ -function harnessHome(runtime: RuntimeName): string { - return join(MOSAIC_HOME, `.${runtime}`); +/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.. + * With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home) + * the home is per-agent instead: /fleet/agents//. — + * per-agent sessions, settings, and auth inside the seat dir (canon §2, + * MOSAIC-D-002). Seat runtime dirs are dot-named so the brain's ignore policy + * (per-seat .pi/.claude/.codex dirs) keeps credential material untracked. */ +const SEAT_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +export function activeSeatDir(mosaicHome: string = MOSAIC_HOME): string | undefined { + const agent = process.env['MOSAIC_AGENT_NAME']?.trim(); + if ( + agent === undefined || + agent === '' || + !SEAT_AGENT_NAME_RE.test(agent) || + agent.includes('..') + ) { + return undefined; + } + const brain = resolveBrainHome(mosaicHome); + if (resolve(brain) === resolve(mosaicHome)) return undefined; // no brain + const seat = join(brain, 'fleet', 'agents', agent); + return existsSync(seat) ? seat : undefined; +} + +function harnessHome(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string { + const seat = activeSeatDir(mosaicHome); + if (seat !== undefined) return join(seat, `.${runtime}`); + return join(mosaicHome, `.${runtime}`); +} + +/** Seat persona block: with an active brain seat, /SOUL.md layers + * persona on the root generic base (canon invariant; MOSAIC-D-002). The base + * SOUL stays load-on-demand — only the seat delta is injected by value. + * Empty string when no seat is active or the seat carries no SOUL.md. */ +export function seatPersonaOverlay(mosaicHome: string = MOSAIC_HOME): string { + const seatDir = activeSeatDir(mosaicHome); + if (seatDir === undefined) return ''; + const seatSoul = readOptional(join(seatDir, 'SOUL.md')); + if (!seatSoul.trim()) return ''; + return '## Seat Persona\n\n' + seatSoul.trim(); } /** @@ -182,6 +220,8 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v cli_version: CLI_VERSION, config_home: harnessHome(runtime), config_home_isolated: true, + config_home_kind: activeSeatDir() !== undefined ? 'seat' : 'runtime-shared', + agent_name: process.env['MOSAIC_AGENT_NAME']?.trim() || null, config_home_env: HARNESS_HOME_ENV[runtime] ?? null, argv: redactArgv(cliArgs), normative_fragments: normativeFragmentDigests(runtime), @@ -569,6 +609,11 @@ For required push/merge/issue-close/release actions, execute without routine con if (soulLocal.trim()) { overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim()); } + // Seat persona (MOSAIC-D-002): per-seat SOUL.md layers on the generic base. + const seatPersona = seatPersonaOverlay(mosaicHome); + if (seatPersona !== '') { + overlayBlocks.push(seatPersona); + } const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md')); if (standardsLocal.trim()) { overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim()); diff --git a/packages/mosaic/src/fleet/brain-home.spec.ts b/packages/mosaic/src/fleet/brain-home.spec.ts new file mode 100644 index 00000000..a071d183 --- /dev/null +++ b/packages/mosaic/src/fleet/brain-home.spec.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + brainHomeIsActive, + fleetAgentEnvDir, + fleetProfilesDir, + fleetRolesLocalDir, + fleetStateDir, + resolveBrainHome, + type BrainHomeOptions, +} from './brain-home.js'; + +describe('fleet brain-home resolution', (): void => { + let cleanup: string | undefined; + + const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME']; + + beforeEach((): void => { + delete process.env['MOSAIC_BRAIN_HOME']; + }); + + afterEach(async (): Promise => { + if (savedBrainEnv === undefined) { + delete process.env['MOSAIC_BRAIN_HOME']; + } else { + process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv; + } + if (cleanup !== undefined) { + await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; + } + }); + + async function makeTmp(): Promise { + const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-')); + cleanup = root; + return root; + } + + it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => { + process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain'; + expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain'); + expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents'); + expect(brainHomeIsActive('/any/mosaic-home')).toBe(true); + }); + + it('injected envBrainHome wins identically (test seam)', (): void => { + const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' }; + expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain'); + expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents'); + }); + + it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => { + const mosaicHome = '/tmp/not-the-default-config-home'; + expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome); + expect(brainHomeIsActive(mosaicHome)).toBe(false); + expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents')); + }); + + it('the default config home adopts the brain when it carries fleet/agents', async (): Promise => { + const root = await makeTmp(); + const brain = join(root, 'brain'); + await mkdir(join(brain, 'fleet', 'agents'), { recursive: true }); + const configHome = join(root, 'config', 'mosaic'); + const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } }; + + expect(resolveBrainHome(configHome, opts)).toBe(brain); + expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents')); + expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local')); + expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles')); + expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet')); + expect(brainHomeIsActive(configHome, opts)).toBe(true); + }); + + it('the default config home stays legacy when no brain exists', async (): Promise => { + const root = await makeTmp(); + const configHome = join(root, 'config', 'mosaic'); + const opts: BrainHomeOptions = { + homes: { brain: join(root, 'brain'), configDefault: configHome }, + }; + + expect(resolveBrainHome(configHome, opts)).toBe(configHome); + expect(brainHomeIsActive(configHome, opts)).toBe(false); + }); + + it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => { + process.env['MOSAIC_BRAIN_HOME'] = ' '; + expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home'); + }); + + it('adoption requires fleet/agents specifically, not any brain content', async (): Promise => { + const root = await makeTmp(); + const brain = join(root, 'brain'); + await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents + const configHome = join(root, 'config', 'mosaic'); + const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } }; + + expect(resolveBrainHome(configHome, opts)).toBe(configHome); + }); + + it('real-home control: a host brain is adopted only through the default home', (): void => { + // Control on the un-injected path: this host carries ~/.mosaic/fleet/agents, + // so the default config home resolves to the brain or legacy — both valid + // canonical endpoints — while a non-default home never adopts. + const defaultHome = join(homedir(), '.config', 'mosaic'); + const resolved = resolveBrainHome(defaultHome); + expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved); + expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe( + join(homedir(), 'elsewhere', 'mosaic'), + ); + }); +}); diff --git a/packages/mosaic/src/fleet/brain-home.ts b/packages/mosaic/src/fleet/brain-home.ts new file mode 100644 index 00000000..1c5fd689 --- /dev/null +++ b/packages/mosaic/src/fleet/brain-home.ts @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +/** + * Overridable resolution inputs (tests inject tmp homes; production reads + * the environment and the real home directory). + */ +export interface BrainHomeOptions { + /** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */ + readonly envBrainHome?: string; + /** + * Canonical homes used for adoption. Defaults derive from the real + * `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`. + */ + readonly homes?: { readonly brain: string; readonly configDefault: string }; +} + +/** + * Brain-home resolution — the three-tree fleet split (stack canon + * `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain): + * + * config home (~/.config/mosaic) framework templates + dispatch state: + * fleet/roles (baseline), fleet/roster.yaml, + * fleet/run (heartbeats), fleet/services + * brain home (~/.mosaic) user-owned fleet state, committed: + * fleet/agents/.env.*, fleet/roles.local, + * fleet/profiles working copies + * + * Resolution order: + * 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins) + * 2. canonical `~/.mosaic` — adopted ONLY when mosaicHome is the real + * default config home AND `~/.mosaic/fleet/agents` exists. Custom + * `--mosaic-home` values (tests, sandboxes, canaries) never trigger + * adoption, keeping them hermetic and deterministic. + * 3. mosaicHome itself (legacy single-tree behavior). + */ +export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string { + const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME']; + if (explicit !== undefined && explicit.trim() !== '') { + return explicit; + } + const homes = options.homes ?? { + brain: join(homedir(), '.mosaic'), + configDefault: join(homedir(), '.config', 'mosaic'), + }; + if (resolve(mosaicHome) !== resolve(homes.configDefault)) { + return mosaicHome; + } + return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome; +} + +/** True when fleet state resolves somewhere other than the config home. */ +export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean { + return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome); +} + +/** Fleet state root (brain home when active, else the config home). */ +export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string { + return join(resolveBrainHome(mosaicHome, options), 'fleet'); +} + +/** Seat launch envs — `/fleet/agents` when a brain is active. */ +export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string { + return join(fleetStateDir(mosaicHome, options), 'agents'); +} + +/** PRESERVE-protected persona override layer — `/fleet/roles.local`. */ +export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string { + return join(fleetStateDir(mosaicHome, options), 'roles.local'); +} + +/** System-type profiles (user working copies) — `/fleet/profiles`. */ +export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string { + return join(fleetStateDir(mosaicHome, options), 'profiles'); +} diff --git a/packages/mosaic/src/fleet/fleet-reconciler.ts b/packages/mosaic/src/fleet/fleet-reconciler.ts index 732c878d..60bbdadf 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.ts @@ -3,6 +3,7 @@ import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { fleetAgentEnvDir } from './brain-home.js'; import { applyPreparedAgentEnvironmentProjection, prepareAgentEnvironmentProjection, @@ -617,7 +618,7 @@ function defaultPrepareProjections( (agent: FleetRosterV2Agent): Promise => prepareAgentEnvironmentProjection({ mosaicHome, - agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + agentEnvDir: fleetAgentEnvDir(mosaicHome), agentName: agent.name, generated: projectRosterV2AgentGeneratedEnv(roster, agent), }), diff --git a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts index c0c8a96e..09c9fd59 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts @@ -176,6 +176,52 @@ describe('generated fleet agent environment boundary', (): void => { expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600); }); + it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise => { + const savedBrainHome = process.env['MOSAIC_BRAIN_HOME']; + try { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); + const mosaicHome = join(cleanup, 'config-home'); + const brainHome = join(cleanup, 'brain'); + const agentEnvDir = join(brainHome, 'fleet', 'agents'); + process.env['MOSAIC_BRAIN_HOME'] = brainHome; + + const result = await writeAgentEnvironmentProjection({ + mosaicHome, + agentEnvDir, + agentName: 'coder0', + generated: generatedValues, + }); + + // Projection landed in the brain tree, not under the config home. + expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated')); + expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700); + expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700); + expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600); + await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow(); + + // A config-home agentEnvDir is now REJECTED while the brain is active — + // the boundary must not silently split state across two trees. + let rejected: unknown; + try { + await writeAgentEnvironmentProjection({ + mosaicHome, + agentEnvDir: join(mosaicHome, 'fleet', 'agents'), + agentName: 'coder1', + generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' }, + }); + } catch (caught: unknown) { + rejected = caught; + } + expect(rejected).toBeInstanceOf(AgentEnvBoundaryError); + } finally { + if (savedBrainHome === undefined) { + delete process.env['MOSAIC_BRAIN_HOME']; + } else { + process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome; + } + } + }); + it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise => { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); const mosaicHome = join(cleanup, 'mosaic'); diff --git a/packages/mosaic/src/fleet/generated-env-boundary.ts b/packages/mosaic/src/fleet/generated-env-boundary.ts index cd849440..4db94203 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js'; import { compareCodePoints } from './deterministic-order.js'; export type AgentEnvironmentKind = 'generated' | 'local'; @@ -528,12 +529,15 @@ async function validatePrivateProjectionDirectory( mosaicHome: string, agentEnvDir: string, ): Promise { - const fleetDir = join(mosaicHome, 'fleet'); - const expectedAgentEnvDir = join(fleetDir, 'agents'); + // Brain-home split (canon §2): seat envs live under the brain home's + // fleet/agents when a brain is active; roster + templates stay config-home. + const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome); if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) { throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir); } - await assertManagedDirectoryIfPresent(mosaicHome, false); + const stateHome = resolveBrainHome(mosaicHome); + const fleetDir = join(stateHome, 'fleet'); + await assertManagedDirectoryIfPresent(stateHome, false); await assertManagedDirectoryIfPresent(fleetDir, false); await assertManagedDirectoryIfPresent(agentEnvDir, true); } @@ -543,8 +547,9 @@ async function ensurePrivateProjectionDirectory( agentEnvDir: string, ): Promise { await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir); - const fleetDir = join(mosaicHome, 'fleet'); - await ensureManagedDirectory(mosaicHome, false); + const stateHome = resolveBrainHome(mosaicHome); + const fleetDir = join(stateHome, 'fleet'); + await ensureManagedDirectory(stateHome, false); await ensureManagedDirectory(fleetDir, false); await ensureManagedDirectory(agentEnvDir, true); }