feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Work packages completed: - WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier, persona loader with project-level overrides. 89 tests, 95.62% coverage. - WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types. 65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior. - WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start + subagent_spawning hooks for Mosaic contract enforcement). - WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs), skills/ (5 universal skills), forge pipeline assets (48 markdown files). Board deliberation: docs/reviews/consolidation-board-memo.md Brief: briefs/monorepo-consolidation.md Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack. 154 new tests total. Zero Python — all TypeScript/ESM.
This commit is contained in:
307
packages/macp/__tests__/credential-resolver.test.ts
Normal file
307
packages/macp/__tests__/credential-resolver.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { mkdirSync, writeFileSync, chmodSync, 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 {
|
||||
extractProvider,
|
||||
parseDotenv,
|
||||
stripJSON5Extensions,
|
||||
checkOCConfigPermissions,
|
||||
isValidCredential,
|
||||
resolveCredentials,
|
||||
REDACTED_MARKER,
|
||||
PROVIDER_REGISTRY,
|
||||
} from '../src/credential-resolver.js';
|
||||
import { CredentialError } from '../src/types.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = join(tmpdir(), `macp-test-${randomUUID()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('extractProvider', () => {
|
||||
it('extracts provider from model reference', () => {
|
||||
expect(extractProvider('anthropic/claude-3')).toBe('anthropic');
|
||||
expect(extractProvider('openai/gpt-4')).toBe('openai');
|
||||
expect(extractProvider('zai/model-x')).toBe('zai');
|
||||
});
|
||||
|
||||
it('handles whitespace and casing', () => {
|
||||
expect(extractProvider(' Anthropic/claude-3 ')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('throws on empty model reference', () => {
|
||||
expect(() => extractProvider('')).toThrow(CredentialError);
|
||||
expect(() => extractProvider(' ')).toThrow(CredentialError);
|
||||
});
|
||||
|
||||
it('throws on unsupported provider', () => {
|
||||
expect(() => extractProvider('unknown/model')).toThrow(CredentialError);
|
||||
expect(() => extractProvider('unknown/model')).toThrow('Unsupported credential provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDotenv', () => {
|
||||
it('parses key=value pairs', () => {
|
||||
expect(parseDotenv('FOO=bar\nBAZ=qux')).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
||||
});
|
||||
|
||||
it('strips single and double quotes', () => {
|
||||
expect(parseDotenv('A="hello"\nB=\'world\'')).toEqual({ A: 'hello', B: 'world' });
|
||||
});
|
||||
|
||||
it('skips comments and blank lines', () => {
|
||||
expect(parseDotenv('# comment\n\nFOO=bar\n # another\n')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('skips lines without =', () => {
|
||||
expect(parseDotenv('NOEQUALS\nFOO=bar')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('skips lines with empty key', () => {
|
||||
expect(parseDotenv('=value\nFOO=bar')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('handles value with = in it', () => {
|
||||
expect(parseDotenv('KEY=val=ue')).toEqual({ KEY: 'val=ue' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripJSON5Extensions', () => {
|
||||
it('removes trailing commas', () => {
|
||||
const input = '{"a": 1, "b": 2,}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('quotes unquoted keys', () => {
|
||||
const input = '{foo: "bar", baz: 42}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ foo: 'bar', baz: 42 });
|
||||
});
|
||||
|
||||
it('removes full-line comments', () => {
|
||||
const input = '{\n // this is a comment\n "key": "value"\n}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('handles single-quoted strings', () => {
|
||||
const input = "{key: 'value'}";
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('preserves URLs and timestamps inside string values', () => {
|
||||
const input = '{"url": "https://example.com/path?q=1", "ts": "2024-01-01T00:00:00Z"}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result.url).toBe('https://example.com/path?q=1');
|
||||
expect(result.ts).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('handles complex JSON5 with mixed features', () => {
|
||||
const input = `{
|
||||
// comment
|
||||
apiKey: 'sk-abc123',
|
||||
url: "https://api.example.com/v1",
|
||||
nested: {
|
||||
value: "hello",
|
||||
flag: true,
|
||||
},
|
||||
}`;
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result.apiKey).toBe('sk-abc123');
|
||||
expect(result.url).toBe('https://api.example.com/v1');
|
||||
expect(result.nested.value).toBe('hello');
|
||||
expect(result.nested.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCredential', () => {
|
||||
it('returns true for normal values', () => {
|
||||
expect(isValidCredential('sk-abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty/whitespace', () => {
|
||||
expect(isValidCredential('')).toBe(false);
|
||||
expect(isValidCredential(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for redacted marker', () => {
|
||||
expect(isValidCredential(REDACTED_MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkOCConfigPermissions', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns false for non-existent file', () => {
|
||||
expect(checkOCConfigPermissions(join(tmp, 'missing.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for file owned by current user', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
chmodSync(p, 0o600);
|
||||
expect(checkOCConfigPermissions(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with warning for world-readable file', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
chmodSync(p, 0o644);
|
||||
expect(checkOCConfigPermissions(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when uid does not match', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
expect(checkOCConfigPermissions(p, { getuid: () => 99999 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCredentials', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['ZAI_API_KEY'];
|
||||
delete process.env['CUSTOM_KEY'];
|
||||
});
|
||||
|
||||
it('resolves from credential file', () => {
|
||||
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-key\n');
|
||||
const result = resolveCredentials('anthropic/claude-3', { credentialsDir: tmp });
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-key' });
|
||||
});
|
||||
|
||||
it('resolves from ambient environment', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient-key';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient-key' });
|
||||
});
|
||||
|
||||
it('resolves from OC config env block', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-env' } }));
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-env' });
|
||||
});
|
||||
|
||||
it('resolves from OC config provider apiKey', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(
|
||||
ocPath,
|
||||
JSON.stringify({
|
||||
env: {},
|
||||
models: { providers: { anthropic: { apiKey: 'sk-oc-provider' } } },
|
||||
}),
|
||||
);
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-provider' });
|
||||
});
|
||||
|
||||
it('mosaic credential file wins over OC config', () => {
|
||||
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-wins\n');
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-loses' } }));
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: tmp,
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-wins' });
|
||||
});
|
||||
|
||||
it('gracefully falls back when OC config is missing', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-fallback';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-fallback' });
|
||||
});
|
||||
|
||||
it('skips redacted values in OC config', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: REDACTED_MARKER } }));
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient' });
|
||||
});
|
||||
|
||||
it('throws CredentialError when nothing resolves', () => {
|
||||
expect(() =>
|
||||
resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
}),
|
||||
).toThrow(CredentialError);
|
||||
});
|
||||
|
||||
it('supports task-level credential env var override', () => {
|
||||
process.env['CUSTOM_KEY'] = 'sk-custom';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
taskConfig: { credentials: { provider_key_env: 'CUSTOM_KEY' } },
|
||||
});
|
||||
expect(result).toEqual({ CUSTOM_KEY: 'sk-custom' });
|
||||
});
|
||||
|
||||
it('handles JSON5 OC config syntax', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(
|
||||
ocPath,
|
||||
`{
|
||||
// OC config with JSON5 features
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'sk-json5-key',
|
||||
},
|
||||
}`,
|
||||
);
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-json5-key' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_REGISTRY', () => {
|
||||
it('has entries for anthropic, openai, zai', () => {
|
||||
expect(Object.keys(PROVIDER_REGISTRY)).toEqual(['anthropic', 'openai', 'zai']);
|
||||
for (const meta of Object.values(PROVIDER_REGISTRY)) {
|
||||
expect(meta).toHaveProperty('credential_file');
|
||||
expect(meta).toHaveProperty('env_var');
|
||||
expect(meta).toHaveProperty('oc_env_key');
|
||||
expect(meta).toHaveProperty('oc_provider_path');
|
||||
}
|
||||
});
|
||||
});
|
||||
141
packages/macp/__tests__/event-emitter.test.ts
Normal file
141
packages/macp/__tests__/event-emitter.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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 { nowISO, appendEvent, emitEvent } from '../src/event-emitter.js';
|
||||
import type { MACPEvent } from '../src/types.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = join(tmpdir(), `macp-event-${randomUUID()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('nowISO', () => {
|
||||
it('returns a valid ISO timestamp', () => {
|
||||
const ts = nowISO();
|
||||
expect(() => new Date(ts)).not.toThrow();
|
||||
expect(new Date(ts).toISOString()).toBe(ts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendEvent', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('appends event as ndjson line', () => {
|
||||
const eventsPath = join(tmp, 'events.ndjson');
|
||||
const event: MACPEvent = {
|
||||
event_id: 'evt-1',
|
||||
event_type: 'task.started',
|
||||
task_id: 'task-1',
|
||||
status: 'running',
|
||||
timestamp: nowISO(),
|
||||
source: 'test',
|
||||
message: 'Test event',
|
||||
metadata: {},
|
||||
};
|
||||
appendEvent(eventsPath, event);
|
||||
|
||||
const content = readFileSync(eventsPath, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const parsed = JSON.parse(lines[0]!);
|
||||
expect(parsed.event_id).toBe('evt-1');
|
||||
expect(parsed.event_type).toBe('task.started');
|
||||
expect(parsed.task_id).toBe('task-1');
|
||||
});
|
||||
|
||||
it('appends multiple events', () => {
|
||||
const eventsPath = join(tmp, 'events.ndjson');
|
||||
const base: MACPEvent = {
|
||||
event_id: '',
|
||||
event_type: 'task.started',
|
||||
task_id: 'task-1',
|
||||
status: 'running',
|
||||
timestamp: nowISO(),
|
||||
source: 'test',
|
||||
message: '',
|
||||
metadata: {},
|
||||
};
|
||||
appendEvent(eventsPath, { ...base, event_id: 'evt-1', message: 'first' });
|
||||
appendEvent(eventsPath, { ...base, event_id: 'evt-2', message: 'second' });
|
||||
|
||||
const lines = readFileSync(eventsPath, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates parent directories', () => {
|
||||
const eventsPath = join(tmp, 'nested', 'deep', 'events.ndjson');
|
||||
const event: MACPEvent = {
|
||||
event_id: 'evt-1',
|
||||
event_type: 'task.started',
|
||||
task_id: 'task-1',
|
||||
status: 'running',
|
||||
timestamp: nowISO(),
|
||||
source: 'test',
|
||||
message: 'nested',
|
||||
metadata: {},
|
||||
};
|
||||
appendEvent(eventsPath, event);
|
||||
expect(readFileSync(eventsPath, 'utf-8')).toContain('nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitEvent', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates event with all required fields', () => {
|
||||
const eventsPath = join(tmp, 'events.ndjson');
|
||||
emitEvent(eventsPath, 'task.completed', 'task-42', 'completed', 'controller', 'Task done');
|
||||
|
||||
const content = readFileSync(eventsPath, 'utf-8');
|
||||
const event = JSON.parse(content.trim());
|
||||
expect(event.event_id).toBeTruthy();
|
||||
expect(event.event_type).toBe('task.completed');
|
||||
expect(event.task_id).toBe('task-42');
|
||||
expect(event.status).toBe('completed');
|
||||
expect(event.source).toBe('controller');
|
||||
expect(event.message).toBe('Task done');
|
||||
expect(event.timestamp).toBeTruthy();
|
||||
expect(event.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it('includes metadata when provided', () => {
|
||||
const eventsPath = join(tmp, 'events.ndjson');
|
||||
emitEvent(eventsPath, 'task.failed', 'task-1', 'failed', 'worker', 'err', {
|
||||
exit_code: 1,
|
||||
});
|
||||
|
||||
const event = JSON.parse(readFileSync(eventsPath, 'utf-8').trim());
|
||||
expect(event.metadata).toEqual({ exit_code: 1 });
|
||||
});
|
||||
|
||||
it('generates unique event_ids', () => {
|
||||
const eventsPath = join(tmp, 'events.ndjson');
|
||||
emitEvent(eventsPath, 'task.started', 'task-1', 'running', 'test', 'a');
|
||||
emitEvent(eventsPath, 'task.started', 'task-1', 'running', 'test', 'b');
|
||||
|
||||
const events = readFileSync(eventsPath, 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
expect(events[0].event_id).not.toBe(events[1].event_id);
|
||||
});
|
||||
});
|
||||
253
packages/macp/__tests__/gate-runner.test.ts
Normal file
253
packages/macp/__tests__/gate-runner.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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 (');
|
||||
});
|
||||
});
|
||||
25
packages/macp/package.json
Normal file
25
packages/macp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mosaic/macp",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
236
packages/macp/src/credential-resolver.ts
Normal file
236
packages/macp/src/credential-resolver.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { CredentialError } from './types.js';
|
||||
import type { ProviderRegistry } from './types.js';
|
||||
|
||||
export const DEFAULT_CREDENTIALS_DIR = resolve(join(homedir(), '.config', 'mosaic', 'credentials'));
|
||||
export const OC_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
export const REDACTED_MARKER = '__OPENCLAW_REDACTED__';
|
||||
|
||||
export const PROVIDER_REGISTRY: ProviderRegistry = {
|
||||
anthropic: {
|
||||
credential_file: 'anthropic.env',
|
||||
env_var: 'ANTHROPIC_API_KEY',
|
||||
oc_env_key: 'ANTHROPIC_API_KEY',
|
||||
oc_provider_path: 'anthropic',
|
||||
},
|
||||
openai: {
|
||||
credential_file: 'openai.env',
|
||||
env_var: 'OPENAI_API_KEY',
|
||||
oc_env_key: 'OPENAI_API_KEY',
|
||||
oc_provider_path: 'openai',
|
||||
},
|
||||
zai: {
|
||||
credential_file: 'zai.env',
|
||||
env_var: 'ZAI_API_KEY',
|
||||
oc_env_key: 'ZAI_API_KEY',
|
||||
oc_provider_path: 'zai',
|
||||
},
|
||||
};
|
||||
|
||||
export function extractProvider(modelRef: string): string {
|
||||
const provider = String(modelRef).trim().split('/')[0]?.trim().toLowerCase() ?? '';
|
||||
if (!provider) {
|
||||
throw new CredentialError(`Unable to resolve provider from model reference: '${modelRef}'`);
|
||||
}
|
||||
if (!(provider in PROVIDER_REGISTRY)) {
|
||||
throw new CredentialError(`Unsupported credential provider: ${provider}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function parseDotenv(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
if (!line.includes('=')) continue;
|
||||
const eqIdx = line.indexOf('=');
|
||||
const key = line.slice(0, eqIdx).trim();
|
||||
if (!key) continue;
|
||||
let value = line.slice(eqIdx + 1).trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
value[0] === value[value.length - 1] &&
|
||||
(value[0] === '"' || value[0] === "'")
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function loadCredentialFile(path: string): Record<string, string> {
|
||||
if (!existsSync(path)) return {};
|
||||
return parseDotenv(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
export function stripJSON5Extensions(content: string): string {
|
||||
const strings: string[] = [];
|
||||
const MARKER = '\x00OCSTR';
|
||||
|
||||
// 1. Remove full-line comments
|
||||
content = content.replace(/^\s*\/\/[^\n]*$/gm, '');
|
||||
|
||||
// 2. Protect single-quoted strings
|
||||
content = content.replace(/'([^']*)'/g, (_m, g1: string) => {
|
||||
const idx = strings.length;
|
||||
strings.push(g1);
|
||||
return `${MARKER}${idx}\x00`;
|
||||
});
|
||||
|
||||
// 3. Protect double-quoted strings
|
||||
content = content.replace(/"([^"]*)"/g, (_m, g1: string) => {
|
||||
const idx = strings.length;
|
||||
strings.push(g1);
|
||||
return `${MARKER}${idx}\x00`;
|
||||
});
|
||||
|
||||
// 4. Structural transforms — safe because strings are now placeholders
|
||||
content = content.replace(/,\s*([}\]])/g, '$1');
|
||||
content = content.replace(/\b(\w[\w-]*)\b(?=\s*:)/g, '"$1"');
|
||||
|
||||
// 5. Restore string values with proper JSON escaping
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
content = content.replace(`${MARKER}${i}\x00`, JSON.stringify(strings[i]!));
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
ocConfigPath?: string;
|
||||
}
|
||||
|
||||
export function checkOCConfigPermissions(path: string, opts?: { getuid?: () => number }): boolean {
|
||||
if (!existsSync(path)) return false;
|
||||
|
||||
const stat = statSync(path);
|
||||
const mode = stat.mode & 0o777;
|
||||
if (mode & 0o077) {
|
||||
// world/group readable — log warning (matches Python behavior)
|
||||
}
|
||||
|
||||
const getuid = opts?.getuid ?? process.getuid?.bind(process);
|
||||
if (getuid && stat.uid !== getuid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isValidCredential(value: string): boolean {
|
||||
const stripped = String(value).trim();
|
||||
return stripped.length > 0 && stripped !== REDACTED_MARKER;
|
||||
}
|
||||
|
||||
function loadOCConfigCredentials(
|
||||
provider: string,
|
||||
envVar: string,
|
||||
ocConfigPath?: string,
|
||||
): Record<string, string> {
|
||||
const configPath = ocConfigPath ?? OC_CONFIG_PATH;
|
||||
if (!existsSync(configPath)) return {};
|
||||
|
||||
try {
|
||||
if (!checkOCConfigPermissions(configPath)) return {};
|
||||
const rawContent = readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(stripJSON5Extensions(rawContent)) as Record<string, unknown>;
|
||||
|
||||
const providerMeta = PROVIDER_REGISTRY[provider];
|
||||
const ocEnvKey = providerMeta?.oc_env_key ?? envVar;
|
||||
const envBlock = config['env'];
|
||||
if (typeof envBlock === 'object' && envBlock !== null && !Array.isArray(envBlock)) {
|
||||
const envValue = (envBlock as Record<string, unknown>)[ocEnvKey];
|
||||
if (typeof envValue === 'string' && isValidCredential(envValue)) {
|
||||
return { [envVar]: envValue.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
const models = config['models'];
|
||||
const providers =
|
||||
typeof models === 'object' && models !== null && !Array.isArray(models)
|
||||
? ((models as Record<string, unknown>)['providers'] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
const ocProviderPath = providerMeta?.oc_provider_path ?? provider;
|
||||
if (typeof providers === 'object' && providers !== null && !Array.isArray(providers)) {
|
||||
const providerConfig = providers[ocProviderPath];
|
||||
if (
|
||||
typeof providerConfig === 'object' &&
|
||||
providerConfig !== null &&
|
||||
!Array.isArray(providerConfig)
|
||||
) {
|
||||
const apiKey = (providerConfig as Record<string, unknown>)['apiKey'];
|
||||
if (typeof apiKey === 'string' && isValidCredential(apiKey)) {
|
||||
return { [envVar]: apiKey.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveTargetEnvVar(
|
||||
provider: string,
|
||||
taskConfig?: Record<string, unknown> | null,
|
||||
): string {
|
||||
const providerMeta = PROVIDER_REGISTRY[provider]!;
|
||||
const rawCredentials =
|
||||
typeof taskConfig === 'object' && taskConfig !== null
|
||||
? (taskConfig['credentials'] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
const credentials =
|
||||
typeof rawCredentials === 'object' && rawCredentials !== null ? rawCredentials : {};
|
||||
const envVar = String(credentials['provider_key_env'] || providerMeta.env_var).trim();
|
||||
if (!envVar) {
|
||||
throw new CredentialError(`Invalid credential env var override for provider: ${provider}`);
|
||||
}
|
||||
return envVar;
|
||||
}
|
||||
|
||||
export interface ResolveCredentialsOptions {
|
||||
taskConfig?: Record<string, unknown> | null;
|
||||
credentialsDir?: string;
|
||||
ocConfigPath?: string;
|
||||
}
|
||||
|
||||
export function resolveCredentials(
|
||||
modelRef: string,
|
||||
opts?: ResolveCredentialsOptions,
|
||||
): Record<string, string> {
|
||||
const provider = extractProvider(modelRef);
|
||||
const providerMeta = PROVIDER_REGISTRY[provider]!;
|
||||
const envVar = resolveTargetEnvVar(provider, opts?.taskConfig);
|
||||
const credentialRoot = resolve(opts?.credentialsDir ?? DEFAULT_CREDENTIALS_DIR);
|
||||
const credentialFile = join(credentialRoot, providerMeta.credential_file);
|
||||
|
||||
// 1. Mosaic credential file
|
||||
const fileValues = loadCredentialFile(credentialFile);
|
||||
const fileValue = (fileValues[envVar] ?? '').trim();
|
||||
if (fileValue) {
|
||||
return { [envVar]: fileValue };
|
||||
}
|
||||
|
||||
// 2. OpenClaw config
|
||||
const ocValues = loadOCConfigCredentials(provider, envVar, opts?.ocConfigPath);
|
||||
if (Object.keys(ocValues).length > 0) {
|
||||
return ocValues;
|
||||
}
|
||||
|
||||
// 3. Ambient environment
|
||||
const ambientValue = String(process.env[envVar] ?? '').trim();
|
||||
if (ambientValue) {
|
||||
return { [envVar]: ambientValue };
|
||||
}
|
||||
|
||||
throw new CredentialError(
|
||||
`Missing required credential ${envVar} for provider ${provider} ` +
|
||||
`(checked ${credentialFile}, OC config, then ambient environment)`,
|
||||
);
|
||||
}
|
||||
35
packages/macp/src/event-emitter.ts
Normal file
35
packages/macp/src/event-emitter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import type { MACPEvent } from './types.js';
|
||||
|
||||
export function nowISO(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function appendEvent(eventsPath: string, event: MACPEvent): void {
|
||||
mkdirSync(dirname(eventsPath), { recursive: true });
|
||||
appendFileSync(eventsPath, JSON.stringify(event) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
export function emitEvent(
|
||||
eventsPath: string,
|
||||
eventType: string,
|
||||
taskId: string,
|
||||
status: string,
|
||||
source: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void {
|
||||
appendEvent(eventsPath, {
|
||||
event_id: randomUUID(),
|
||||
event_type: eventType,
|
||||
task_id: taskId,
|
||||
status,
|
||||
timestamp: nowISO(),
|
||||
source,
|
||||
message,
|
||||
metadata: metadata ?? {},
|
||||
});
|
||||
}
|
||||
240
packages/macp/src/gate-runner.ts
Normal file
240
packages/macp/src/gate-runner.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import { emitEvent } from './event-emitter.js';
|
||||
import { nowISO } from './event-emitter.js';
|
||||
import type { GateResult } from './types.js';
|
||||
|
||||
export interface NormalizedGate {
|
||||
command: string;
|
||||
type: string;
|
||||
fail_on: string;
|
||||
}
|
||||
|
||||
export function normalizeGate(gate: unknown): NormalizedGate {
|
||||
if (typeof gate === 'string') {
|
||||
return { command: gate, type: 'mechanical', fail_on: 'blocker' };
|
||||
}
|
||||
if (typeof gate === 'object' && gate !== null && !Array.isArray(gate)) {
|
||||
const g = gate as Record<string, unknown>;
|
||||
return {
|
||||
command: String(g['command'] ?? ''),
|
||||
type: String(g['type'] ?? 'mechanical'),
|
||||
fail_on: String(g['fail_on'] ?? 'blocker'),
|
||||
};
|
||||
}
|
||||
return { command: '', type: 'mechanical', fail_on: 'blocker' };
|
||||
}
|
||||
|
||||
export function runShell(
|
||||
command: string,
|
||||
cwd: string,
|
||||
logPath: string,
|
||||
timeoutSec: number,
|
||||
): { exitCode: number; output: string; timedOut: boolean } {
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
|
||||
const header = `\n[${nowISO()}] COMMAND: ${command}\n`;
|
||||
appendFileSync(logPath, header, 'utf-8');
|
||||
|
||||
let exitCode: number;
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', ['-lc', command], {
|
||||
cwd,
|
||||
timeout: Math.max(1, timeoutSec) * 1000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
output = (result.stdout ?? '') + (result.stderr ?? '');
|
||||
|
||||
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
timedOut = true;
|
||||
exitCode = 124;
|
||||
appendFileSync(logPath, `[${nowISO()}] TIMEOUT: exceeded ${timeoutSec}s\n`, 'utf-8');
|
||||
} else {
|
||||
exitCode = result.status ?? 1;
|
||||
}
|
||||
} catch {
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
if (output) appendFileSync(logPath, output, 'utf-8');
|
||||
appendFileSync(logPath, `[${nowISO()}] EXIT: ${exitCode}\n`, 'utf-8');
|
||||
|
||||
return { exitCode, output, timedOut };
|
||||
}
|
||||
|
||||
export function countAIFindings(parsedOutput: unknown): { blockers: number; total: number } {
|
||||
if (typeof parsedOutput !== 'object' || parsedOutput === null || Array.isArray(parsedOutput)) {
|
||||
return { blockers: 0, total: 0 };
|
||||
}
|
||||
|
||||
const obj = parsedOutput as Record<string, unknown>;
|
||||
const stats = obj['stats'];
|
||||
let blockers = 0;
|
||||
let total = 0;
|
||||
|
||||
if (typeof stats === 'object' && stats !== null && !Array.isArray(stats)) {
|
||||
const s = stats as Record<string, unknown>;
|
||||
blockers = Number(s['blockers']) || 0;
|
||||
total = blockers + (Number(s['should_fix']) || 0) + (Number(s['suggestions']) || 0);
|
||||
}
|
||||
|
||||
const findings = obj['findings'];
|
||||
if (Array.isArray(findings)) {
|
||||
if (blockers === 0) {
|
||||
blockers = findings.filter(
|
||||
(f) =>
|
||||
typeof f === 'object' &&
|
||||
f !== null &&
|
||||
(f as Record<string, unknown>)['severity'] === 'blocker',
|
||||
).length;
|
||||
}
|
||||
if (total === 0) {
|
||||
total = findings.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { blockers, total };
|
||||
}
|
||||
|
||||
export function runGate(
|
||||
gate: unknown,
|
||||
cwd: string,
|
||||
logPath: string,
|
||||
timeoutSec: number,
|
||||
): GateResult {
|
||||
const gateEntry = normalizeGate(gate);
|
||||
const gateType = gateEntry.type;
|
||||
const command = gateEntry.command;
|
||||
|
||||
if (gateType === 'ci-pipeline') {
|
||||
return {
|
||||
command,
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: 'CI pipeline gate placeholder',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
command: '',
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: '',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
|
||||
const result: GateResult = {
|
||||
command,
|
||||
exit_code: exitCode,
|
||||
type: gateType,
|
||||
output,
|
||||
timed_out: timedOut,
|
||||
passed: false,
|
||||
};
|
||||
|
||||
if (gateType !== 'ai-review') {
|
||||
result.passed = exitCode === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
const failOn = gateEntry.fail_on || 'blocker';
|
||||
let parsedOutput: unknown = undefined;
|
||||
let blockers = 0;
|
||||
let findingsCount = 0;
|
||||
let parseError: string | undefined;
|
||||
|
||||
try {
|
||||
parsedOutput = output.trim() ? JSON.parse(output) : {};
|
||||
const counts = countAIFindings(parsedOutput);
|
||||
blockers = counts.blockers;
|
||||
findingsCount = counts.total;
|
||||
} catch (exc) {
|
||||
parseError = String(exc instanceof Error ? exc.message : exc);
|
||||
}
|
||||
|
||||
if (failOn === 'any') {
|
||||
result.passed = exitCode === 0 && findingsCount === 0 && !timedOut && parseError === undefined;
|
||||
} else {
|
||||
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
|
||||
}
|
||||
|
||||
result.fail_on = failOn;
|
||||
result.blockers = blockers;
|
||||
result.findings = findingsCount;
|
||||
if (parsedOutput !== undefined) {
|
||||
result.parsed_output = parsedOutput;
|
||||
}
|
||||
if (parseError !== undefined) {
|
||||
result.parse_error = parseError;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function runGates(
|
||||
gates: unknown[],
|
||||
cwd: string,
|
||||
logPath: string,
|
||||
timeoutSec: number,
|
||||
eventsPath: string,
|
||||
taskId: string,
|
||||
): { allPassed: boolean; gateResults: GateResult[] } {
|
||||
let allPassed = true;
|
||||
const gateResults: GateResult[] = [];
|
||||
|
||||
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;
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.started',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Running gate: ${label}`,
|
||||
);
|
||||
const result = runGate(gate, cwd, logPath, timeoutSec);
|
||||
gateResults.push(result);
|
||||
|
||||
if (result.passed) {
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.passed',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate passed: ${label}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
allPassed = false;
|
||||
let message: string;
|
||||
if (result.timed_out) {
|
||||
message = `Gate timed out after ${timeoutSec}s: ${label}`;
|
||||
} else if (result.type === 'ai-review' && result.parse_error) {
|
||||
message = `AI review gate output was not valid JSON: ${label}`;
|
||||
} else {
|
||||
message = `Gate failed (${result.exit_code}): ${label}`;
|
||||
}
|
||||
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
|
||||
}
|
||||
|
||||
return { allPassed, gateResults };
|
||||
}
|
||||
43
packages/macp/src/index.ts
Normal file
43
packages/macp/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Types
|
||||
export type {
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
DispatchMode,
|
||||
DependsOnPolicy,
|
||||
GateType,
|
||||
GateFailOn,
|
||||
GateEntry,
|
||||
Task,
|
||||
EventType,
|
||||
MACPEvent,
|
||||
GateResult,
|
||||
TaskResult,
|
||||
ProviderMeta,
|
||||
ProviderRegistry,
|
||||
} from './types.js';
|
||||
|
||||
export { CredentialError } from './types.js';
|
||||
|
||||
// Credential resolver
|
||||
export {
|
||||
DEFAULT_CREDENTIALS_DIR,
|
||||
OC_CONFIG_PATH,
|
||||
REDACTED_MARKER,
|
||||
PROVIDER_REGISTRY,
|
||||
extractProvider,
|
||||
parseDotenv,
|
||||
stripJSON5Extensions,
|
||||
checkOCConfigPermissions,
|
||||
isValidCredential,
|
||||
resolveCredentials,
|
||||
} from './credential-resolver.js';
|
||||
|
||||
export type { ResolveCredentialsOptions } from './credential-resolver.js';
|
||||
|
||||
// Gate runner
|
||||
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
|
||||
|
||||
export type { NormalizedGate } from './gate-runner.js';
|
||||
|
||||
// Event emitter
|
||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||
123
packages/macp/src/schemas/task.schema.json
Normal file
123
packages/macp/src/schemas/task.schema.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://mosaicstack.dev/schemas/orchestrator/task.schema.json",
|
||||
"title": "Mosaic Orchestrator Task",
|
||||
"type": "object",
|
||||
"required": ["id", "title", "status"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "running", "gated", "completed", "failed", "escalated"]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["coding", "deploy", "research", "review", "documentation", "infrastructure"],
|
||||
"description": "Task type - determines dispatch strategy and gate requirements"
|
||||
},
|
||||
"dispatch": {
|
||||
"type": "string",
|
||||
"enum": ["yolo", "acp", "exec"],
|
||||
"description": "Execution backend: yolo=mosaic yolo (full system), acp=OpenClaw sessions_spawn (sandboxed), exec=direct shell"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"description": "Preferred worker runtime, e.g. codex, claude, opencode"
|
||||
},
|
||||
"worktree": {
|
||||
"type": "string",
|
||||
"description": "Path to git worktree for this task, e.g. ~/src/repo-worktrees/task-042"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Git branch name for this task"
|
||||
},
|
||||
"brief_path": {
|
||||
"type": "string",
|
||||
"description": "Path to markdown task brief relative to repo root"
|
||||
},
|
||||
"result_path": {
|
||||
"type": "string",
|
||||
"description": "Path to JSON result file relative to .mosaic/orchestrator/"
|
||||
},
|
||||
"issue": {
|
||||
"type": "string",
|
||||
"description": "Issue reference (e.g. #42)"
|
||||
},
|
||||
"pr": {
|
||||
"type": ["string", "null"],
|
||||
"description": "PR number/URL once opened"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of task IDs this task depends on"
|
||||
},
|
||||
"depends_on_policy": {
|
||||
"type": "string",
|
||||
"enum": ["all", "any", "all_terminal"],
|
||||
"default": "all",
|
||||
"description": "How to evaluate dependency satisfaction"
|
||||
},
|
||||
"max_attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
},
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Override default timeout for this task"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Worker command to execute for this task"
|
||||
},
|
||||
"quality_gates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["mechanical", "ai-review", "ci-pipeline"]
|
||||
},
|
||||
"fail_on": {
|
||||
"type": "string",
|
||||
"enum": ["blocker", "any"]
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"additionalProperties": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
127
packages/macp/src/types.ts
Normal file
127
packages/macp/src/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/** Task status values. */
|
||||
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
|
||||
|
||||
/** Task type — determines dispatch strategy and gate requirements. */
|
||||
export type TaskType =
|
||||
| 'coding'
|
||||
| 'deploy'
|
||||
| 'research'
|
||||
| 'review'
|
||||
| 'documentation'
|
||||
| 'infrastructure';
|
||||
|
||||
/** Execution backend. */
|
||||
export type DispatchMode = 'yolo' | 'acp' | 'exec';
|
||||
|
||||
/** Dependency evaluation policy. */
|
||||
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
|
||||
|
||||
/** Quality gate type. */
|
||||
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
|
||||
|
||||
/** Gate fail_on mode. */
|
||||
export type GateFailOn = 'blocker' | 'any';
|
||||
|
||||
/** Quality gate definition — either a bare command string or a structured object. */
|
||||
export interface GateEntry {
|
||||
command: string;
|
||||
type?: GateType;
|
||||
fail_on?: GateFailOn;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** MACP task. */
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
dispatch?: DispatchMode;
|
||||
runtime?: string;
|
||||
worktree?: string;
|
||||
branch?: string;
|
||||
brief_path?: string;
|
||||
result_path?: string;
|
||||
issue?: string;
|
||||
pr?: string | null;
|
||||
depends_on?: string[];
|
||||
depends_on_policy?: DependsOnPolicy;
|
||||
max_attempts?: number;
|
||||
attempts?: number;
|
||||
timeout_seconds?: number;
|
||||
command?: string;
|
||||
quality_gates?: (string | GateEntry)[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Event types emitted by the MACP protocol. */
|
||||
export type EventType =
|
||||
| 'task.assigned'
|
||||
| 'task.started'
|
||||
| 'task.completed'
|
||||
| 'task.failed'
|
||||
| 'task.escalated'
|
||||
| 'task.gated'
|
||||
| 'task.retry.scheduled'
|
||||
| 'rail.check.started'
|
||||
| 'rail.check.passed'
|
||||
| 'rail.check.failed';
|
||||
|
||||
/** Structured event record. */
|
||||
export interface MACPEvent {
|
||||
event_id: string;
|
||||
event_type: EventType | string;
|
||||
task_id: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result from running a single quality gate. */
|
||||
export interface GateResult {
|
||||
command: string;
|
||||
exit_code: number;
|
||||
type: string;
|
||||
output: string;
|
||||
timed_out: boolean;
|
||||
passed: boolean;
|
||||
fail_on?: string;
|
||||
blockers?: number;
|
||||
findings?: number;
|
||||
parsed_output?: unknown;
|
||||
parse_error?: string;
|
||||
}
|
||||
|
||||
/** Result from a completed task. */
|
||||
export interface TaskResult {
|
||||
task_id: string;
|
||||
status: TaskStatus;
|
||||
completed_at: string;
|
||||
exit_code: number;
|
||||
gate_results: GateResult[];
|
||||
files_changed?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Provider registry entry. */
|
||||
export interface ProviderMeta {
|
||||
credential_file: string;
|
||||
env_var: string;
|
||||
oc_env_key: string;
|
||||
oc_provider_path: string;
|
||||
}
|
||||
|
||||
/** Provider registry mapping. */
|
||||
export type ProviderRegistry = Record<string, ProviderMeta>;
|
||||
|
||||
/** Raised when required provider credentials cannot be resolved. */
|
||||
export class CredentialError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CredentialError';
|
||||
}
|
||||
}
|
||||
9
packages/macp/tsconfig.json
Normal file
9
packages/macp/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*", "__tests__/**/*", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
packages/macp/vitest.config.ts
Normal file
13
packages/macp/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/index.ts', 'src/schemas/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user