Compare commits

..
Author SHA1 Message Date
fargo 2097379e25 fix(macp): fail-closed typed gate states — no placeholder/empty-command passes (#1275)
ci/woodpecker/pr/ci Pipeline was successful
RI-N2 / SDLC-D-035. Every GateResult now carries a typed status
discriminator (passed|failed|simulated|waiting|capability_failure);
passed:true remains true only for really-executed, really-green gates.

- ci-pipeline without a CI provider → capability_failure (MACP_NO_CI_PIPELINE),
  never the placeholder pass
- empty-command gate → capability_failure (MACP_NO_COMMAND / MACP_NO_REVIEWER),
  and runGates no longer silently skips it
- manual gate type waits (MACP_AUTHORITY_REQUIRED) — neither pass nor fail
- explicit simulation only (simulate option / --simulate): typed simulated
  results can never make the aggregate passed (RunGatesResult.state)
- CLI stubs (tasks list, submit, events tail) exit nonzero with typed
  MACP_NOT_IMPLEMENTED; macp gate is now wired to runGates with --simulate
- legacy __tests__/gate-runner.test.ts consolidated into src/gate-runner.spec.ts
  (root eslint project service does not cover packages/macp/__tests__)
2026-08-17 16:40:16 -05:00
27 changed files with 981 additions and 973 deletions
-253
View File
@@ -1,253 +0,0 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-gate-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gate.log');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline always passes', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.type).toBe('ci-pipeline');
expect(result.output).toBe('CI pipeline gate placeholder');
});
it('empty command passes', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gates.log');
eventsPath = join(tmp, 'events.ndjson');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('skips gates with empty command (non ci-pipeline)', () => {
const { gateResults } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(1);
});
it('does not skip ci-pipeline even with empty command', () => {
const { gateResults } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(true);
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
+163 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { Command } from 'commander';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
@@ -75,3 +78,162 @@ describe('registerMacpCommand', () => {
expect(topLevel).toContain('events');
});
});
/**
* RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure,
* never a success. Every stub exits nonzero with a typed message, and the
* implemented `macp gate` mirrors the typed gate-runner states.
*/
describe('registerMacpCommand fail-closed (RI-N2)', () => {
let tmpDir: string;
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeErr: () => {} });
registerMacpCommand(program);
return program;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-'));
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('macp tasks list exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => {
const program = buildProgram();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(errText).toContain('MACP_NOT_IMPLEMENTED');
} finally {
errSpy.mockRestore();
}
});
it('macp events tail exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp gate runs a green inline command and exits 0', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(0);
});
it('macp gate exits nonzero on a failing command', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 9',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => {
const program = buildProgram();
const specPath = path.join(tmpDir, 'gates.json');
fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }]));
await program.parseAsync(
[
'macp',
'gate',
specPath,
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate --simulate completes (exit 0) but reports simulated results', async () => {
const program = buildProgram();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--simulate',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
// completes only because the caller explicitly asked to simulate
expect(process.exitCode).toBe(0);
const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(outText).toContain('simulated');
expect(outText).toContain('SIMULATED');
} finally {
logSpy.mockRestore();
}
});
it('macp gate with an empty spec exits nonzero with a typed error', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
' ',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
});
+129 -19
View File
@@ -1,5 +1,73 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Command } from 'commander';
import { runGates } from './gate-runner.js';
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
/**
* Load gates from a spec: an existing file (JSON gates array, a JSON object
* with `quality_gates`, a JSON gate object, or one command per line) or an
* inline command string. Fails closed with a typed capability error when the
* spec contains no executable gate definition.
*/
function loadGateSpec(spec: string): unknown[] {
if (existsSync(spec)) {
const raw = readFileSync(spec, 'utf-8');
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains an empty gates array`,
);
}
return parsed;
}
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj['quality_gates'])) {
return obj['quality_gates'];
}
return [parsed];
}
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
);
} catch (exc) {
if (exc instanceof MACPCapabilityError) throw exc;
// Not JSON — treat each non-empty line as a command gate.
const lines = raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length > 0) return lines;
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains no gates`,
);
}
}
if (spec.trim().length > 0) return [spec];
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
}
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
function notImplemented(subcommand: string, capability: string, hint: string): void {
const err = new MACPCapabilityError(
'MACP_NOT_IMPLEMENTED',
capability,
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
);
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
process.exitCode = 1;
}
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
@@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void {
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
});
// ─── submit ──────────────────────────────────────────────────────────────
@@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void {
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
});
// ─── gate ────────────────────────────────────────────────────────────────
@@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void {
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
.option(
'--simulate',
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
)
.action(
(
spec: string,
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
) => {
let gates: unknown[];
try {
gates = loadGateSpec(spec);
} catch (exc) {
if (exc instanceof MACPCapabilityError) {
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
} else {
console.error(`[macp] gate: ${String(exc)}`);
}
process.exitCode = 1;
return;
}
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
const eventsPath = `${opts.log}.events.ndjson`;
const { state, gateResults } = runGates(
gates,
opts.cwd,
opts.log,
timeoutSec,
eventsPath,
'macp-cli-gate',
{
simulate: opts.simulate,
},
);
for (const r of gateResults) {
const label = r.command || r.type;
const reason = r.reason ? `${r.reason}` : '';
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
}
if (opts.simulate) {
console.log(
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
);
}
// Simulated runs may complete (exit 0) only because the caller
// explicitly passed --simulate; the typed state stays 'simulated'.
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
},
);
// ─── events ──────────────────────────────────────────────────────────────
@@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void {
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
});
}
// Re-export so CLI consumers can surface typed capability codes.
export type { MacpErrorCode };
+35
View File
@@ -0,0 +1,35 @@
/** Typed error code from the closed MACP_ERROR_CODES set. */
export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number];
/**
* Typed fail-closed capability errors (RI-N2, SDLC-D-035).
*
* MACP must fail closed when a required capability (executor, reviewer,
* command, CI provider, human authority) is absent. These typed codes mirror
* the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same
* language: an unimplemented capability is a failure, never a stub success.
*/
/** Closed set of typed MACP capability error codes. */
export const MACP_ERROR_CODES = [
'MACP_NOT_IMPLEMENTED',
'MACP_NO_COMMAND',
'MACP_NO_REVIEWER',
'MACP_NO_CI_PIPELINE',
'MACP_NO_PROVIDER',
'MACP_AUTHORITY_REQUIRED',
] as const;
/** Raised when a required capability is missing and execution must fail closed. */
export class MACPCapabilityError extends Error {
/** Typed error code from the closed MACP_ERROR_CODES set. */
readonly code: MacpErrorCode;
/** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */
readonly capability: string;
constructor(code: MacpErrorCode, capability: string, message: string) {
super(message);
this.name = 'MACPCapabilityError';
this.code = code;
this.capability = capability;
}
}
+429
View File
@@ -0,0 +1,429 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-'));
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(result.type).toBe('ci-pipeline');
expect(result.output).not.toBe('CI pipeline gate placeholder');
});
it('empty command is a typed capability failure, never a pass', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_COMMAND');
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = fs.readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gates.log');
eventsPath = path.join(tmp, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('does not silently skip gates with empty command — they become capability failures', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(2);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(gateResults[1]!.status).toBe('passed');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('does not skip ci-pipeline even with empty command — typed capability failure', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(false);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
/**
* RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner.
*
* Invariant under test: `passed: true` occurs ONLY when a gate really executed
* and really exited green (`status === 'passed'`). Absent capabilities,
* manual sign-offs, and simulated runs are typed distinctly and can never
* make the aggregate `passed`.
*/
describe('gate-runner fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
eventsPath = path.join(tmpDir, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(gates: unknown[], options?: { simulate?: boolean }) {
return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options);
}
// ─── positive controls ───────────────────────────────────────────────────
it('a really-executed green command gate still passes', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.gateResults[0]!.passed).toBe(true);
expect(result.allPassed).toBe(true);
expect(result.state).toBe('passed');
});
it('explicit simulate completes and types every result simulated', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], {
simulate: true,
});
expect(result.gateResults).toHaveLength(2);
for (const gate of result.gateResults) {
expect(gate.status).toBe('simulated');
expect(gate.passed).toBe(false);
}
expect(result.state).toBe('simulated');
});
it('a really-executed red command gate fails with typed status failed', () => {
const result = run([{ command: 'exit 3', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('failed');
expect(result.gateResults[0]!.passed).toBe(false);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('failed');
});
// ─── negative controls — each asserts typed status AND aggregate not passed ──
it('an empty-command gate is a capability_failure, not skipped and not passed', () => {
const result = run([{ command: '', type: 'mechanical' }]);
// runGates must not silently skip it — it produces a typed result
expect(result.gateResults).toHaveLength(1);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_COMMAND');
expect(gate.passed).toBe(false);
// aggregate is not passed
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
expect(result.state).not.toBe('passed');
});
it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => {
const result = run([{ command: '', type: 'ai-review' }]);
expect(result.gateResults[0]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => {
const result = run([{ command: '', type: 'ci-pipeline' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(gate.passed).toBe(false);
// the old false-success placeholder must be gone
expect(gate.output).not.toBe('CI pipeline gate placeholder');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => {
const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]);
expect(result.gateResults[1]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
});
it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => {
const result = run([{ type: 'manual' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('waiting');
expect(gate.passed).toBe(false);
expect(gate.exit_code).toBe(0);
// aggregate is not passed while any gate is waiting
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
expect(result.state).not.toBe('passed');
});
it('a simulated result can never make the aggregate passed', () => {
const result = run(['exit 0', 'exit 0'], { simulate: true });
expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('simulated');
expect(result.state).not.toBe('passed');
});
it('waiting dominates an otherwise green aggregate', () => {
const result = run(['exit 0', { type: 'manual' }]);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
});
});
describe('runGate fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('simulate: true returns a typed simulated result without executing', () => {
const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, {
simulate: true,
});
expect(result.status).toBe('simulated');
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(0);
});
it('normal mode executes for real and types a green gate passed', () => {
const result = runGate('echo ok', tmpDir, logPath, 10);
expect(result.status).toBe('passed');
expect(result.passed).toBe(true);
expect(result.output).toContain('ok');
});
it('a bare string gate normalizes to mechanical and executes', () => {
const result = runGate('exit 7', tmpDir, logPath, 10);
expect(result.type).toBe('mechanical');
expect(result.status).toBe('failed');
expect(result.passed).toBe(false);
});
});
+148 -25
View File
@@ -4,7 +4,20 @@ import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult } from './types.js';
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
/** Typed reason stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
export interface RunGateOptions {
/**
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
* every result is typed `simulated` and never satisfies anything.
*/
simulate?: boolean;
}
export interface NormalizedGate {
command: string;
@@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
return { blockers, total };
}
function simulatedResult(gateEntry: NormalizedGate): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: SIMULATED_GATE_REASON,
timed_out: false,
passed: false,
status: 'simulated',
reason: SIMULATED_GATE_REASON,
};
}
function capabilityFailureResult(
gateEntry: NormalizedGate,
code: GateResult['capability_code'],
reason: string,
): GateResult {
return {
command: gateEntry.command,
exit_code: 1,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'capability_failure',
capability_code: code,
reason,
};
}
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'waiting',
capability_code: 'MACP_AUTHORITY_REQUIRED',
reason,
};
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
options: RunGateOptions = {},
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
// Explicit simulation only: never executes, typed simulated, never satisfying.
if (options.simulate) {
return simulatedResult(gateEntry);
}
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
if (gateType === 'ci-pipeline') {
return {
command,
exit_code: 0,
type: gateType,
output: 'CI pipeline gate placeholder',
timed_out: false,
passed: true,
};
return capabilityFailureResult(
gateEntry,
'MACP_NO_CI_PIPELINE',
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
);
}
if (!command) {
return {
command: '',
exit_code: 0,
type: gateType,
output: '',
timed_out: false,
passed: true,
};
// A manual gate with no automation waits for human sign-off: not pass, not fail.
if (gateType === 'manual') {
return waitingResult(
gateEntry,
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
);
}
// Any other commandless gate is an absent capability — never a vacuous pass.
return capabilityFailureResult(
gateEntry,
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
);
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
@@ -143,10 +211,12 @@ export function runGate(
output,
timed_out: timedOut,
passed: false,
status: 'failed',
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
result.status = result.passed ? 'passed' : 'failed';
return result;
}
@@ -170,6 +240,7 @@ export function runGate(
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.status = result.passed ? 'passed' : 'failed';
result.fail_on = failOn;
result.blockers = blockers;
@@ -191,16 +262,19 @@ export function runGates(
timeoutSec: number,
eventsPath: string,
taskId: string,
): { allPassed: boolean; gateResults: GateResult[] } {
let allPassed = true;
options: RunGateOptions = {},
): RunGatesResult {
const gateResults: GateResult[] = [];
let hasCapabilityFailure = false;
let hasSimulated = false;
let hasFailed = false;
let hasWaiting = false;
for (const gate of gates) {
const gateEntry = normalizeGate(gate);
const gateCmd = gateEntry.command;
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
const label = gateCmd || gateEntry.type;
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
emitEvent(
eventsPath,
'rail.check.started',
@@ -209,10 +283,10 @@ export function runGates(
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec);
const result = runGate(gate, cwd, logPath, timeoutSec, options);
gateResults.push(result);
if (result.passed) {
if (result.status === 'passed') {
emitEvent(
eventsPath,
'rail.check.passed',
@@ -224,7 +298,46 @@ export function runGates(
continue;
}
allPassed = false;
if (result.status === 'waiting') {
hasWaiting = true;
emitEvent(
eventsPath,
'rail.check.waiting',
taskId,
'gated',
'quality-gate',
`Gate waiting: ${label}${result.reason ?? 'manual gate awaits sign-off'}`,
);
continue;
}
if (result.status === 'simulated') {
hasSimulated = true;
emitEvent(
eventsPath,
'rail.check.simulated',
taskId,
'gated',
'quality-gate',
`Gate simulated (non-satisfying): ${label}`,
);
continue;
}
if (result.status === 'capability_failure') {
hasCapabilityFailure = true;
emitEvent(
eventsPath,
'rail.check.failed',
taskId,
'gated',
'quality-gate',
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label}${result.reason ?? 'required capability is absent'}`,
);
continue;
}
hasFailed = true;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
@@ -236,5 +349,15 @@ export function runGates(
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
return { allPassed, gateResults };
const state: GateStatus = hasCapabilityFailure
? 'capability_failure'
: hasSimulated
? 'simulated'
: hasFailed
? 'failed'
: hasWaiting
? 'waiting'
: 'passed';
return { allPassed: state === 'passed', gateResults, state };
}
+16 -2
View File
@@ -6,11 +6,13 @@ export type {
DependsOnPolicy,
GateType,
GateFailOn,
GateStatus,
GateEntry,
Task,
EventType,
MACPEvent,
GateResult,
RunGatesResult,
TaskResult,
ProviderMeta,
ProviderRegistry,
@@ -18,6 +20,11 @@ export type {
export { CredentialError } from './types.js';
// Typed fail-closed capability errors (RI-N2, SDLC-D-035)
export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js';
export type { MacpErrorCode } from './errors.js';
// Credential resolver
export {
DEFAULT_CREDENTIALS_DIR,
@@ -35,9 +42,16 @@ export {
export type { ResolveCredentialsOptions } from './credential-resolver.js';
// Gate runner
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
export {
normalizeGate,
runShell,
countAIFindings,
runGate,
runGates,
SIMULATED_GATE_REASON,
} from './gate-runner.js';
export type { NormalizedGate } from './gate-runner.js';
export type { NormalizedGate, RunGateOptions } from './gate-runner.js';
// Risk-floor (agent reflection loop — diff review classifier)
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
+39 -2
View File
@@ -1,3 +1,5 @@
import type { MacpErrorCode } from './errors.js';
/** Task status values. */
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
@@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec';
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
/** Quality gate type. */
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual';
/**
* Typed execution state of a gate — closed set (RI-N2, SDLC-D-035).
*
* Only `passed` means "really executed and green". `simulated` is produced
* exclusively under an explicit simulate opt-in and never satisfies anything.
* `capability_failure` means a required executor/provider/command was absent.
* `waiting` means a manual gate awaits human sign-off (neither pass nor fail).
*/
export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure';
/** Gate fail_on mode. */
export type GateFailOn = 'blocker' | 'any';
@@ -67,7 +79,9 @@ export type EventType =
| 'task.retry.scheduled'
| 'rail.check.started'
| 'rail.check.passed'
| 'rail.check.failed';
| 'rail.check.failed'
| 'rail.check.waiting'
| 'rail.check.simulated';
/** Structured event record. */
export interface MACPEvent {
@@ -88,7 +102,14 @@ export interface GateResult {
type: string;
output: string;
timed_out: boolean;
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
passed: boolean;
/** Typed discriminator — the authoritative gate outcome (RI-N2). */
status: GateStatus;
/** Typed capability error code, set when `status === 'capability_failure'`. */
capability_code?: MacpErrorCode;
/** Why a non-executed state (simulated/waiting/capability_failure) was reached. */
reason?: string;
fail_on?: string;
blockers?: number;
findings?: number;
@@ -96,6 +117,22 @@ export interface GateResult {
parse_error?: string;
}
/**
* Aggregate outcome of `runGates` (RI-N2).
*
* `state` is the typed aggregate: it is `passed` only when every gate really
* executed green. A `simulated` result makes the aggregate `simulated` (never
* `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing
* capability makes it `capability_failure`. `allPassed` is exactly
* `state === 'passed'`, so a simulated or waiting result can never satisfy a
* dependency, acceptance criterion, gate, merge, or release check.
*/
export interface RunGatesResult {
allPassed: boolean;
gateResults: GateResult[];
state: GateStatus;
}
/** Result from a completed task. */
export interface TaskResult {
task_id: string;
-27
View File
@@ -12,33 +12,6 @@ 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/<seat>.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.
@@ -255,68 +255,6 @@ fleet_declared_transport() {
printf '%s\n' "${declared:-tmux}"
}
# Brain-home fleet-state resolution (#1298; canon STRUCTURE-CANON §2).
#
# Seat launch envs, roles.local overrides, and profile working copies resolve
# from the brain home when one is active; roster, baseline roles, run/, and
# services stay under MOSAIC_HOME. This check surfaces which tree fleet state
# resolves from and the drift a launch would otherwise hit at runtime:
#
# - a stale MOSAIC_BRAIN_HOME pointing at a directory with no fleet/agents is a
# misconfiguration the resolver honors (explicit wins) — warn, don't pass;
# - a symlinked brain or agents dir defeats the managed-directory boundary;
# - a group/world-readable agents dir violates the 0700 projection boundary;
# - env files left in the config-home tree while a brain is active are split
# state — the write path rejects NEW split writes, but nothing would ever
# tell the operator the old files are stranded.
resolve_brain_home() {
local explicit="${MOSAIC_BRAIN_HOME:-}"
if [[ -n "$(printf '%s' "$explicit" | tr -d '[:space:]')" ]]; then
printf '%s' "$explicit"
return
fi
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
&& -d "$HOME/.mosaic/fleet/agents" ]]; then
printf '%s' "$HOME/.mosaic"
return
fi
printf '%s' "$MOSAIC_HOME"
}
check_brain_home() {
local brain agents mode
brain="$(resolve_brain_home)"
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
return
fi
agents="$brain/fleet/agents"
if [[ ! -d "$agents" ]]; then
warn "Brain home '$brain' has no fleet/agents — seat envs will not resolve from it. Point MOSAIC_BRAIN_HOME at a brain carrying fleet/agents, or unset it."
return
fi
if [[ -L "$brain" || -L "$agents" ]]; then
warn "Brain fleet-state path resolves through a symlink ($brain) — the managed-directory boundary requires regular directories."
return
fi
mode="$(stat -c '%a' -- "$agents" 2>/dev/null)" || mode=""
if [[ -n "$mode" ]] && (( (8#$mode & 8#077) != 0 )); then
warn "Brain agents dir '$agents' is group/world-accessible (mode $mode) — the projection boundary requires 0700."
return
fi
if [[ -d "$MOSAIC_HOME/fleet/agents" ]] \
&& ls "$MOSAIC_HOME/fleet/agents/"*.env* >/dev/null 2>&1; then
warn "Fleet env files exist in BOTH trees — brain '$brain' is active but '$MOSAIC_HOME/fleet/agents' still carries env files (split state). Migrate them (mosaic fleet regen) and remove the config-home copies."
return
fi
pass "Fleet state home: $brain (brain active); roster + templates: $MOSAIC_HOME"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
@@ -335,8 +273,6 @@ check_fleet_transport() {
check_fleet_transport
check_brain_home
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
# Covers the brain-home fleet-state check in `mosaic-doctor` (#1298 follow-up).
#
# The functions are extracted from the shipped script rather than copied here
# (same discipline as test-fleet-transport-check.sh): a test that carries its
# own copy of the logic keeps passing after the shipped copy changes.
# Extraction is by exact function header and a closing brace in column one.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
extract_function() {
local name="$1"
local extracted
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
printf '%s\n' "$extracted"
}
for fn in resolve_brain_home check_brain_home; do
extract_function "$fn" >/dev/null
done
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
pass() { echo "[OK] $*"; return 0; }
eval "$(extract_function resolve_brain_home)"
eval "$(extract_function check_brain_home)"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
run_case() {
# label, expect (ok|warn), then env assignments as arguments.
# The check runs under `env` in a subshell, so its warn() also prints a
# sentinel the parent counts — a subshell counter would never be visible.
local label="$1" expect="$2"
shift 2
local out warns
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
if [[ "$expect" == ok && "$warns" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
echo "ok - $label (warned)"
else
echo "output: $out" >&2
fail "$label: expected $expect (warns=$warns)"
fi
}
# ── legacy: no brain, custom home never adopts ─────────────────────────────
mkdir -p "$ROOT/legacy-mosaic/fleet/agents"
run_case "custom home without brain stays legacy" ok \
MOSAIC_HOME="$ROOT/legacy-mosaic" HOME="$ROOT"
# ── healthy brain at the default config home ───────────────────────────────
mkdir -p "$ROOT/home/.config/mosaic" "$ROOT/home/.mosaic/fleet/agents"
chmod 700 "$ROOT/home/.mosaic/fleet/agents"
run_case "default home adopts healthy brain" ok \
MOSAIC_HOME="$ROOT/home/.config/mosaic" HOME="$ROOT/home"
# ── explicit MOSAIC_BRAIN_HOME to a brain without fleet/agents → warn ──────
mkdir -p "$ROOT/brain-noagents/fleet" "$ROOT/config"
run_case "explicit brain without agents warns" warn \
MOSAIC_HOME="$ROOT/config" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-noagents"
# ── explicit MOSAIC_BRAIN_HOME to a healthy brain → ok ─────────────────────
mkdir -p "$ROOT/brain-ok/fleet/agents" "$ROOT/config2"
chmod 700 "$ROOT/brain-ok/fleet/agents"
run_case "explicit healthy brain passes" ok \
MOSAIC_HOME="$ROOT/config2" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-ok"
# ── group-readable agents dir → warn (0700 boundary) ───────────────────────
mkdir -p "$ROOT/brain-loose/fleet/agents" "$ROOT/config3"
chmod 750 "$ROOT/brain-loose/fleet/agents"
run_case "group-readable brain agents warns" warn \
MOSAIC_HOME="$ROOT/config3" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-loose"
# ── symlinked agents dir → warn (managed-directory boundary) ───────────────
mkdir -p "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet" "$ROOT/config4"
ln -s "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet/agents"
run_case "symlinked brain agents warns" warn \
MOSAIC_HOME="$ROOT/config4" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-link"
# ── split state: envs in BOTH trees → warn ─────────────────────────────────
mkdir -p "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
chmod 700 "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
touch "$ROOT/config5/fleet/agents/coder0.env.generated"
run_case "env files in both trees warns (split state)" warn \
MOSAIC_HOME="$ROOT/config5" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-split"
# ── config-home agents dir WITHOUT env files alongside a brain → ok ────────
mkdir -p "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
run_case "empty config-home agents dir alongside brain passes" ok \
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
echo "ok - mosaic-doctor brain-home check"
@@ -80,26 +80,6 @@ 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"
@@ -167,54 +167,6 @@ 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" <<EOF
MOSAIC_AGENT_NAME=coder-brain
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
install_pane_binaries "$HOME_BRAIN"
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
# Negative control: the SAME default-config-home shape but without
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
: > "$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.
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -1,6 +1,5 @@
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,
@@ -150,9 +149,9 @@ async function executeCommand(
request,
mosaicHome,
rosterPath,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: fleetRolesLocalDir(mosaicHome),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
@@ -1,6 +1,5 @@
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,
@@ -121,11 +120,11 @@ export function registerFleetMigrationCommand(
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
@@ -30,21 +30,19 @@ 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; config home — framework). */
/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge).
* Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */
/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return fleetRolesLocalDir(mosaicHome);
return join(mosaicHome, 'fleet', 'roles.local');
}
/**
@@ -25,7 +25,6 @@ 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,
@@ -37,10 +36,9 @@ function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Directory holding the seeded profile yaml files — brain home when active
* (user working copies, committed), else the config home seed. */
/** Directory holding the seeded profile yaml files. */
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
return fleetProfilesDir(mosaicHome);
return join(mosaicHome, 'fleet', 'profiles');
}
/** Directory holding the persona role contracts. */
@@ -3,7 +3,6 @@ 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,
@@ -154,7 +153,7 @@ export async function executeFleetRegen(
options: FleetRegenOptions,
): Promise<FleetRegenResult> {
const mosaicHome = defaultMosaicHome(deps);
const agentEnvDir = fleetAgentEnvDir(mosaicHome);
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
+1 -2
View File
@@ -13,7 +13,6 @@ 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';
@@ -159,7 +158,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
};
}
@@ -349,90 +349,3 @@ 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('');
});
});
+4 -49
View File
@@ -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, resolve } from 'node:path';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -29,7 +29,6 @@ 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';
@@ -65,46 +64,9 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
* 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();
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
}
/**
@@ -220,8 +182,6 @@ 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),
@@ -609,11 +569,6 @@ 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());
@@ -1,114 +0,0 @@
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<void> => {
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<string> {
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<void> => {
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<void> => {
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<void> => {
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'),
);
});
});
-76
View File
@@ -1,76 +0,0 @@
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/<seat>.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 — `<brainHome>/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 — `<brainHome>/fleet/roles.local`. */
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'roles.local');
}
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'profiles');
}
@@ -3,7 +3,6 @@ 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,
@@ -618,7 +617,7 @@ function defaultPrepareProjections(
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: agent.name,
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
}),
@@ -176,52 +176,6 @@ 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<void> => {
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<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
@@ -2,7 +2,6 @@ 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';
@@ -529,15 +528,12 @@ async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
// 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);
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await assertManagedDirectoryIfPresent(stateHome, false);
await assertManagedDirectoryIfPresent(mosaicHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
@@ -547,9 +543,8 @@ async function ensurePrivateProjectionDirectory(
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await ensureManagedDirectory(stateHome, false);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}