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/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); }