feat(launch): per-agent harness homes + seat persona layer (MOSAIC-D-002)
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
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 — <brainHome>/fleet/agents/<seat>/.<runtime> — instead of the shared per-runtime ~/.config/mosaic/.<runtime>. 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(): <seat>/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).
This commit is contained in:
@@ -349,3 +349,90 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
|||||||
expect(mockExit).not.toHaveBeenCalled();
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir, hostname } from 'node:os';
|
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 type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
buildResolvedFleetCommsBlock,
|
buildResolvedFleetCommsBlock,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
|
import { resolveBrainHome } from '../fleet/brain-home.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||||
|
|
||||||
@@ -64,9 +65,46 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|||||||
opencode: 'XDG_CONFIG_HOME',
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
|
||||||
function harnessHome(runtime: RuntimeName): string {
|
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
|
||||||
return join(MOSAIC_HOME, `.${runtime}`);
|
* the home is per-agent instead: <brainHome>/fleet/agents/<seat>/.<runtime> —
|
||||||
|
* 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, <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,
|
cli_version: CLI_VERSION,
|
||||||
config_home: harnessHome(runtime),
|
config_home: harnessHome(runtime),
|
||||||
config_home_isolated: true,
|
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,
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
argv: redactArgv(cliArgs),
|
argv: redactArgv(cliArgs),
|
||||||
normative_fragments: normativeFragmentDigests(runtime),
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
@@ -569,6 +609,11 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
if (soulLocal.trim()) {
|
if (soulLocal.trim()) {
|
||||||
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + 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'));
|
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
||||||
if (standardsLocal.trim()) {
|
if (standardsLocal.trim()) {
|
||||||
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user