chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@mosaicstack/macp",
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/macp"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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', () => {
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride(); // prevent process.exit in tests
|
||||
registerMacpCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('registers a "macp" command on the parent', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp');
|
||||
expect(macpCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers "macp tasks" subcommand group', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
|
||||
expect(tasksCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
|
||||
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
|
||||
expect(listCmd).toBeDefined();
|
||||
const optionNames = listCmd!.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--status');
|
||||
expect(optionNames).toContain('--type');
|
||||
});
|
||||
|
||||
it('registers "macp submit" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
|
||||
expect(submitCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers "macp gate" subcommand with --fail-on flag', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
|
||||
expect(gateCmd).toBeDefined();
|
||||
const optionNames = gateCmd!.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--fail-on');
|
||||
});
|
||||
|
||||
it('registers "macp events" subcommand group', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
|
||||
expect(eventsCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('registers "macp events tail" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
|
||||
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
|
||||
expect(tailCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('has all required top-level subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||
const topLevel = macpCmd.commands.map((c) => c.name());
|
||||
expect(topLevel).toContain('tasks');
|
||||
expect(topLevel).toContain('submit');
|
||||
expect(topLevel).toContain('gate');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
* caller's Command instance directly.
|
||||
*/
|
||||
export function registerMacpCommand(parent: Command): void {
|
||||
const macp = parent.command('macp').description('MACP task and gate management');
|
||||
|
||||
// ─── tasks ───────────────────────────────────────────────────────────────
|
||||
|
||||
const tasks = macp.command('tasks').description('Manage MACP tasks');
|
||||
|
||||
tasks
|
||||
.command('list')
|
||||
.description('List MACP tasks')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Filter by task status (pending|running|gated|completed|failed|escalated)',
|
||||
)
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
||||
)
|
||||
.action((opts: { status?: string; type?: string }) => {
|
||||
// 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}`);
|
||||
}
|
||||
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
|
||||
});
|
||||
|
||||
// ─── submit ──────────────────────────────────────────────────────────────
|
||||
|
||||
macp
|
||||
.command('submit <path>')
|
||||
.description('Submit a task from a JSON/YAML spec file')
|
||||
.action((specPath: string) => {
|
||||
// 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)');
|
||||
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
|
||||
});
|
||||
|
||||
// ─── gate ────────────────────────────────────────────────────────────────
|
||||
|
||||
macp
|
||||
.command('gate <spec>')
|
||||
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
|
||||
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
|
||||
.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')
|
||||
.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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const events = macp.command('events').description('Stream MACP events');
|
||||
|
||||
events
|
||||
.command('tail')
|
||||
.description('Tail MACP events from the event log (wraps event emitter)')
|
||||
.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 }) => {
|
||||
// unimplemented capability — a failure, never a success (RI-N2)
|
||||
if (opts.file) {
|
||||
console.log(` file: ${opts.file}`);
|
||||
}
|
||||
if (opts.follow) {
|
||||
console.log(' mode: follow');
|
||||
}
|
||||
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export so CLI consumers can surface typed capability codes.
|
||||
export type { MacpErrorCode };
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? {},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
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, 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;
|
||||
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('sh', ['-c', 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 };
|
||||
}
|
||||
|
||||
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 capabilityFailureResult(
|
||||
gateEntry,
|
||||
'MACP_NO_CI_PIPELINE',
|
||||
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
// 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);
|
||||
const result: GateResult = {
|
||||
command,
|
||||
exit_code: exitCode,
|
||||
type: gateType,
|
||||
output,
|
||||
timed_out: timedOut,
|
||||
passed: false,
|
||||
status: 'failed',
|
||||
};
|
||||
|
||||
if (gateType !== 'ai-review') {
|
||||
result.passed = exitCode === 0;
|
||||
result.status = result.passed ? 'passed' : 'failed';
|
||||
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.status = result.passed ? 'passed' : 'failed';
|
||||
|
||||
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,
|
||||
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;
|
||||
const label = gateCmd || gateEntry.type;
|
||||
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.started',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Running gate: ${label}`,
|
||||
);
|
||||
const result = runGate(gate, cwd, logPath, timeoutSec, options);
|
||||
gateResults.push(result);
|
||||
|
||||
if (result.status === 'passed') {
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.passed',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate passed: ${label}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
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}`;
|
||||
} 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);
|
||||
}
|
||||
|
||||
const state: GateStatus = hasCapabilityFailure
|
||||
? 'capability_failure'
|
||||
: hasSimulated
|
||||
? 'simulated'
|
||||
: hasFailed
|
||||
? 'failed'
|
||||
: hasWaiting
|
||||
? 'waiting'
|
||||
: 'passed';
|
||||
|
||||
return { allPassed: state === 'passed', gateResults, state };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Types
|
||||
export type {
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
DispatchMode,
|
||||
DependsOnPolicy,
|
||||
GateType,
|
||||
GateFailOn,
|
||||
GateStatus,
|
||||
GateEntry,
|
||||
Task,
|
||||
EventType,
|
||||
MACPEvent,
|
||||
GateResult,
|
||||
RunGatesResult,
|
||||
TaskResult,
|
||||
ProviderMeta,
|
||||
ProviderRegistry,
|
||||
} from './types.js';
|
||||
|
||||
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,
|
||||
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,
|
||||
SIMULATED_GATE_REASON,
|
||||
} 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';
|
||||
|
||||
export type { ReviewSurface, RiskFloorInput, RiskFloorVerdict } from './risk-floor.js';
|
||||
|
||||
// Event emitter
|
||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||
|
||||
// CLI
|
||||
export { registerMacpCommand } from './cli.js';
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_RISK_THRESHOLD, evaluateRiskFloor, type ReviewSurface } from './risk-floor.js';
|
||||
|
||||
describe('evaluateRiskFloor', () => {
|
||||
it('returns a no-review "none" verdict for an empty diff', () => {
|
||||
const v = evaluateRiskFloor({ filesChanged: [] });
|
||||
expect(v).toEqual({
|
||||
needs_review: false,
|
||||
score: 0,
|
||||
surface: 'none',
|
||||
reason: 'no files changed',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores empty/non-string entries', () => {
|
||||
const v = evaluateRiskFloor({ filesChanged: ['', ' ' as unknown as string].filter(Boolean) });
|
||||
// only the whitespace string survives the Boolean filter; it classifies to none
|
||||
expect(v.surface).toBe('none');
|
||||
expect(v.needs_review).toBe(false);
|
||||
});
|
||||
|
||||
it.each<[string, string, ReviewSurface, boolean]>([
|
||||
['auth', 'apps/api/src/auth/session.guard.ts', 'auth', true],
|
||||
['data', 'packages/db/migrations/0007_add_users.sql', 'data', true],
|
||||
['infra', '.woodpecker/deploy.yml', 'infra', true],
|
||||
['build', 'packages/types/tsconfig.json', 'build', true],
|
||||
['ui', 'apps/web/src/components/Button.tsx', 'ui', false],
|
||||
['test', 'packages/macp/src/risk-floor.spec.ts', 'test', false],
|
||||
['docs', 'docs/plans/agent-reflection-loop-PRD.md', 'docs', false],
|
||||
['none', 'README', 'none', false],
|
||||
])(
|
||||
'classifies a single %s file → surface=%s needs_review=%s',
|
||||
(_label, file, surface, needsReview) => {
|
||||
const v = evaluateRiskFloor({ filesChanged: [file] });
|
||||
expect(v.surface).toBe(surface);
|
||||
expect(v.needs_review).toBe(needsReview);
|
||||
expect(v.reason).toContain(
|
||||
file === 'README' ? 'no sensitive surface' : surface === 'none' ? '' : surface,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('lets the highest-risk surface dominate a mixed diff', () => {
|
||||
const v = evaluateRiskFloor({
|
||||
filesChanged: [
|
||||
'docs/readme.md',
|
||||
'apps/web/src/components/Nav.tsx',
|
||||
'apps/api/src/auth/token.service.ts',
|
||||
],
|
||||
});
|
||||
expect(v.surface).toBe('auth');
|
||||
expect(v.score).toBe(1.0);
|
||||
expect(v.needs_review).toBe(true);
|
||||
expect(v.reason).toContain('token.service.ts');
|
||||
expect(v.reason).not.toContain('readme.md');
|
||||
});
|
||||
|
||||
it('names every file that ties at the dominant surface', () => {
|
||||
const v = evaluateRiskFloor({
|
||||
filesChanged: ['src/login.ts', 'src/permission-check.ts'],
|
||||
});
|
||||
expect(v.surface).toBe('auth');
|
||||
expect(v.reason).toContain('src/login.ts');
|
||||
expect(v.reason).toContain('src/permission-check.ts');
|
||||
});
|
||||
|
||||
it('treats docs+test-only diffs as below the floor', () => {
|
||||
const v = evaluateRiskFloor({
|
||||
filesChanged: ['docs/guide.md', 'packages/x/src/x.test.ts'],
|
||||
});
|
||||
expect(v.needs_review).toBe(false);
|
||||
expect(v.surface).toBe('test'); // higher weight than docs
|
||||
});
|
||||
|
||||
it('honors a custom threshold', () => {
|
||||
const docsOnly = { filesChanged: ['docs/guide.md'] };
|
||||
expect(evaluateRiskFloor(docsOnly, 0.05).needs_review).toBe(true);
|
||||
expect(evaluateRiskFloor(docsOnly, DEFAULT_RISK_THRESHOLD).needs_review).toBe(false);
|
||||
});
|
||||
|
||||
it('is deterministic across call order', () => {
|
||||
const a = evaluateRiskFloor({ filesChanged: ['a.md', 'auth/x.ts', 'b.tsx'] });
|
||||
const b = evaluateRiskFloor({ filesChanged: ['b.tsx', 'a.md', 'auth/x.ts'] });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Diff risk-floor — deterministic review-need classifier.
|
||||
*
|
||||
* Given the set of changed files in a diff, derive a *minimum* review
|
||||
* requirement ("floor") from the change surface. This is the mechanical half
|
||||
* of the agent reflection loop (design §6): risky surfaces (auth, data, infra)
|
||||
* trip a review requirement regardless of what the agent self-reports.
|
||||
*
|
||||
* Precedence (authoritative ordering, see design §5):
|
||||
* CI/tests > human merge > reviewer verdict > self-reflection
|
||||
* This module sits at the *floor*. It NEVER overrides CI or a human; a
|
||||
* `needs_review: false` verdict means "no surface tripped the floor", not
|
||||
* "safe to merge". Consumers MUST keep CI/tests authoritative above it.
|
||||
*
|
||||
* Pure and deterministic: no IO, no clock, no randomness. Same input → same
|
||||
* verdict. Safe to call from a Stop hook via `node -e` or to port inline.
|
||||
*/
|
||||
|
||||
/** Review surfaces, ordered most- to least-sensitive. */
|
||||
export type ReviewSurface = 'auth' | 'data' | 'infra' | 'build' | 'ui' | 'test' | 'docs' | 'none';
|
||||
|
||||
export interface RiskFloorInput {
|
||||
/** Paths of changed files, repo-relative. Order-insensitive. */
|
||||
filesChanged: string[];
|
||||
/** Optional diff size signals; reserved for future weighting. */
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
export interface RiskFloorVerdict {
|
||||
/** True when the change surface meets/exceeds the review threshold. */
|
||||
needs_review: boolean;
|
||||
/** Aggregate risk score in [0, 1] — the max surface weight across files. */
|
||||
score: number;
|
||||
/** The dominant (highest-weight) surface across all changed files. */
|
||||
surface: ReviewSurface;
|
||||
/** Human-readable explanation naming the surface and tripping files. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Default review threshold; `score >= THRESHOLD` ⇒ `needs_review`. */
|
||||
export const DEFAULT_RISK_THRESHOLD = 0.5;
|
||||
|
||||
interface SurfaceRule {
|
||||
surface: ReviewSurface;
|
||||
weight: number;
|
||||
/** Case-insensitive regex matched against the file path. */
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface classification rules, evaluated highest-weight first. The first
|
||||
* rule whose pattern matches a path classifies that file; the file's surface
|
||||
* is the highest-risk surface it matches (rules are pre-sorted by weight).
|
||||
*/
|
||||
const SURFACE_RULES: readonly SurfaceRule[] = [
|
||||
{
|
||||
surface: 'auth',
|
||||
weight: 1.0,
|
||||
pattern: /auth|login|session|token|permission|rbac|credential|secret/i,
|
||||
},
|
||||
{
|
||||
surface: 'data',
|
||||
weight: 0.9,
|
||||
pattern: /migration|prisma|schema|\.sql|entity|repository|seed/i,
|
||||
},
|
||||
{
|
||||
surface: 'infra',
|
||||
weight: 0.85,
|
||||
pattern: /docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform/i,
|
||||
},
|
||||
{
|
||||
surface: 'build',
|
||||
weight: 0.6,
|
||||
pattern: /package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite/i,
|
||||
},
|
||||
{ surface: 'ui', weight: 0.4, pattern: /\.tsx|\.css|components\/|apps\/web\// },
|
||||
{ surface: 'test', weight: 0.2, pattern: /\.spec\.|\.test\.|__tests__\// },
|
||||
{ surface: 'docs', weight: 0.1, pattern: /\.md$|docs\// },
|
||||
];
|
||||
|
||||
const NONE_WEIGHT = 0.0;
|
||||
|
||||
/** Classify a single path to its highest-risk surface and weight. */
|
||||
function classify(path: string): { surface: ReviewSurface; weight: number } {
|
||||
for (const rule of SURFACE_RULES) {
|
||||
if (rule.pattern.test(path)) {
|
||||
return { surface: rule.surface, weight: rule.weight };
|
||||
}
|
||||
}
|
||||
return { surface: 'none', weight: NONE_WEIGHT };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the review risk-floor for a diff.
|
||||
*
|
||||
* @param input changed files (+ optional size signals)
|
||||
* @param threshold review cutoff; defaults to {@link DEFAULT_RISK_THRESHOLD}
|
||||
*/
|
||||
export function evaluateRiskFloor(
|
||||
input: RiskFloorInput,
|
||||
threshold: number = DEFAULT_RISK_THRESHOLD,
|
||||
): RiskFloorVerdict {
|
||||
const files = (input.filesChanged ?? []).filter((f) => typeof f === 'string' && f.length > 0);
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
needs_review: false,
|
||||
score: 0,
|
||||
surface: 'none',
|
||||
reason: 'no files changed',
|
||||
};
|
||||
}
|
||||
|
||||
let topSurface: ReviewSurface = 'none';
|
||||
let topWeight = NONE_WEIGHT;
|
||||
const tripping: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const { surface, weight } = classify(file);
|
||||
if (weight > topWeight) {
|
||||
topWeight = weight;
|
||||
topSurface = surface;
|
||||
tripping.length = 0;
|
||||
tripping.push(file);
|
||||
} else if (weight === topWeight && surface === topSurface && surface !== 'none') {
|
||||
tripping.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const needs_review = topWeight >= threshold;
|
||||
const reason =
|
||||
topSurface === 'none'
|
||||
? `no sensitive surface in ${files.length} changed file(s)`
|
||||
: `${topSurface} surface (weight ${topWeight}) in: ${tripping.join(', ')}`;
|
||||
|
||||
return { needs_review, score: topWeight, surface: topSurface, reason };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://mosaicstack.dev/schemas/reflection/reflection.v1.schema.json",
|
||||
"title": "Agent Reflection (v1)",
|
||||
"description": "End-of-run reflection sidecar. Mechanical fields are written by the Stop hook; self-reported fields are merged from an optional agent-supplied input and are null when absent (provenance.degraded=true).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"task_ref",
|
||||
"agent",
|
||||
"session_id",
|
||||
"timestamp",
|
||||
"repo",
|
||||
"risk",
|
||||
"files_changed",
|
||||
"provenance"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "reflection.v1"
|
||||
},
|
||||
"task_ref": {
|
||||
"type": "string",
|
||||
"description": "Canonical task ref; derived from REFLECTION_TASK_REF or repo+branch."
|
||||
},
|
||||
"agent": {
|
||||
"type": "string",
|
||||
"description": "Persona/runtime id (REFLECTION_AGENT or 'unknown')."
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "From the Stop payload session_id, else 'unknown'."
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO-8601 UTC capture time."
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repo root basename."
|
||||
},
|
||||
"confidence": {
|
||||
"type": ["number", "null"],
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "SELF-REPORTED. Agent's overall confidence; null when not supplied."
|
||||
},
|
||||
"most_likely_wrong": {
|
||||
"type": ["object", "null"],
|
||||
"description": "SELF-REPORTED. The single most-likely way the work is wrong.",
|
||||
"required": ["surface", "description"],
|
||||
"properties": {
|
||||
"surface": { "$ref": "#/$defs/surface" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"known_not_in_diff": {
|
||||
"type": ["string", "null"],
|
||||
"description": "SELF-REPORTED. What the agent knows that isn't visible in the diff."
|
||||
},
|
||||
"risk": {
|
||||
"type": "object",
|
||||
"description": "MECHANICAL. Output of the diff risk-floor.",
|
||||
"required": ["needs_review", "score", "surface", "reason"],
|
||||
"properties": {
|
||||
"needs_review": { "type": "boolean" },
|
||||
"score": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"surface": { "$ref": "#/$defs/surface" },
|
||||
"reason": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"files_changed": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "MECHANICAL. git diff name-only."
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"required": ["source", "reflection_attempt", "degraded", "reflection_mode"],
|
||||
"properties": {
|
||||
"source": { "const": "stop-hook" },
|
||||
"reflection_attempt": { "type": "integer", "minimum": 1 },
|
||||
"degraded": {
|
||||
"type": "boolean",
|
||||
"description": "True when self-report inputs were missing/unreadable."
|
||||
},
|
||||
"reflection_mode": {
|
||||
"type": "string",
|
||||
"enum": ["off", "solo", "orchestrated"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"surface": {
|
||||
"type": "string",
|
||||
"enum": ["auth", "data", "infra", "build", "ui", "test", "docs", "none"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { MacpErrorCode } from './errors.js';
|
||||
|
||||
/** 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' | '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';
|
||||
|
||||
/** 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'
|
||||
| 'rail.check.waiting'
|
||||
| 'rail.check.simulated';
|
||||
|
||||
/** 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;
|
||||
/** 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;
|
||||
parsed_output?: unknown;
|
||||
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;
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "__tests__"]
|
||||
}
|
||||
@@ -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