From f98bec8e83f55717d79afc225096154a71815ae3 Mon Sep 17 00:00:00 2001 From: Zane Date: Mon, 17 Aug 2026 21:51:18 -0500 Subject: [PATCH] feat(launch): per-agent harness homes + seat persona layer (MOSAIC-D-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With an active brain seat (MOSAIC_AGENT_NAME + seat dir under the brain home), 'mosaic launch' now gives the runtime a per-agent home inside the seat dir — /fleet/agents//. — instead of the shared per-runtime ~/.config/mosaic/.. Per-agent sessions, settings, and auth live inside the seat (canon §2; dot-named so brain ignore policy keeps credential material untracked). - activeSeatDir(): resolves the seat only when a brain is active; agent names validated against a safe charset (path traversal rejected) - seatPersonaOverlay(): /SOUL.md injected by value as a '## Seat Persona' overlay block layering persona on the root generic base - launch record: config_home_kind (seat|runtime-shared) + agent_name - bare launches (no MOSAIC_AGENT_NAME) and non-seat agents keep the shared home — no behavior change without a brain Tests: launch.spec seat-home cases incl. unsafe-name matrix + persona overlay hermetic tests (no host-roster dependency). --- packages/mosaic/src/commands/launch.spec.ts | 87 +++++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 53 ++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) 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());