feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Work packages completed: - WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier, persona loader with project-level overrides. 89 tests, 95.62% coverage. - WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types. 65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior. - WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start + subagent_spawning hooks for Mosaic contract enforcement). - WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs), skills/ (5 universal skills), forge pipeline assets (48 markdown files). Board deliberation: docs/reviews/consolidation-board-memo.md Brief: briefs/monorepo-consolidation.md Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack. 154 new tests total. Zero Python — all TypeScript/ESM.
This commit is contained in:
307
packages/macp/__tests__/credential-resolver.test.ts
Normal file
307
packages/macp/__tests__/credential-resolver.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
extractProvider,
|
||||
parseDotenv,
|
||||
stripJSON5Extensions,
|
||||
checkOCConfigPermissions,
|
||||
isValidCredential,
|
||||
resolveCredentials,
|
||||
REDACTED_MARKER,
|
||||
PROVIDER_REGISTRY,
|
||||
} from '../src/credential-resolver.js';
|
||||
import { CredentialError } from '../src/types.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = join(tmpdir(), `macp-test-${randomUUID()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('extractProvider', () => {
|
||||
it('extracts provider from model reference', () => {
|
||||
expect(extractProvider('anthropic/claude-3')).toBe('anthropic');
|
||||
expect(extractProvider('openai/gpt-4')).toBe('openai');
|
||||
expect(extractProvider('zai/model-x')).toBe('zai');
|
||||
});
|
||||
|
||||
it('handles whitespace and casing', () => {
|
||||
expect(extractProvider(' Anthropic/claude-3 ')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('throws on empty model reference', () => {
|
||||
expect(() => extractProvider('')).toThrow(CredentialError);
|
||||
expect(() => extractProvider(' ')).toThrow(CredentialError);
|
||||
});
|
||||
|
||||
it('throws on unsupported provider', () => {
|
||||
expect(() => extractProvider('unknown/model')).toThrow(CredentialError);
|
||||
expect(() => extractProvider('unknown/model')).toThrow('Unsupported credential provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDotenv', () => {
|
||||
it('parses key=value pairs', () => {
|
||||
expect(parseDotenv('FOO=bar\nBAZ=qux')).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
||||
});
|
||||
|
||||
it('strips single and double quotes', () => {
|
||||
expect(parseDotenv('A="hello"\nB=\'world\'')).toEqual({ A: 'hello', B: 'world' });
|
||||
});
|
||||
|
||||
it('skips comments and blank lines', () => {
|
||||
expect(parseDotenv('# comment\n\nFOO=bar\n # another\n')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('skips lines without =', () => {
|
||||
expect(parseDotenv('NOEQUALS\nFOO=bar')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('skips lines with empty key', () => {
|
||||
expect(parseDotenv('=value\nFOO=bar')).toEqual({ FOO: 'bar' });
|
||||
});
|
||||
|
||||
it('handles value with = in it', () => {
|
||||
expect(parseDotenv('KEY=val=ue')).toEqual({ KEY: 'val=ue' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripJSON5Extensions', () => {
|
||||
it('removes trailing commas', () => {
|
||||
const input = '{"a": 1, "b": 2,}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('quotes unquoted keys', () => {
|
||||
const input = '{foo: "bar", baz: 42}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ foo: 'bar', baz: 42 });
|
||||
});
|
||||
|
||||
it('removes full-line comments', () => {
|
||||
const input = '{\n // this is a comment\n "key": "value"\n}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('handles single-quoted strings', () => {
|
||||
const input = "{key: 'value'}";
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('preserves URLs and timestamps inside string values', () => {
|
||||
const input = '{"url": "https://example.com/path?q=1", "ts": "2024-01-01T00:00:00Z"}';
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result.url).toBe('https://example.com/path?q=1');
|
||||
expect(result.ts).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('handles complex JSON5 with mixed features', () => {
|
||||
const input = `{
|
||||
// comment
|
||||
apiKey: 'sk-abc123',
|
||||
url: "https://api.example.com/v1",
|
||||
nested: {
|
||||
value: "hello",
|
||||
flag: true,
|
||||
},
|
||||
}`;
|
||||
const result = JSON.parse(stripJSON5Extensions(input));
|
||||
expect(result.apiKey).toBe('sk-abc123');
|
||||
expect(result.url).toBe('https://api.example.com/v1');
|
||||
expect(result.nested.value).toBe('hello');
|
||||
expect(result.nested.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCredential', () => {
|
||||
it('returns true for normal values', () => {
|
||||
expect(isValidCredential('sk-abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty/whitespace', () => {
|
||||
expect(isValidCredential('')).toBe(false);
|
||||
expect(isValidCredential(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for redacted marker', () => {
|
||||
expect(isValidCredential(REDACTED_MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkOCConfigPermissions', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns false for non-existent file', () => {
|
||||
expect(checkOCConfigPermissions(join(tmp, 'missing.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for file owned by current user', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
chmodSync(p, 0o600);
|
||||
expect(checkOCConfigPermissions(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with warning for world-readable file', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
chmodSync(p, 0o644);
|
||||
expect(checkOCConfigPermissions(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when uid does not match', () => {
|
||||
const p = join(tmp, 'config.json');
|
||||
writeFileSync(p, '{}');
|
||||
expect(checkOCConfigPermissions(p, { getuid: () => 99999 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCredentials', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['ZAI_API_KEY'];
|
||||
delete process.env['CUSTOM_KEY'];
|
||||
});
|
||||
|
||||
it('resolves from credential file', () => {
|
||||
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-key\n');
|
||||
const result = resolveCredentials('anthropic/claude-3', { credentialsDir: tmp });
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-key' });
|
||||
});
|
||||
|
||||
it('resolves from ambient environment', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient-key';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient-key' });
|
||||
});
|
||||
|
||||
it('resolves from OC config env block', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-env' } }));
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-env' });
|
||||
});
|
||||
|
||||
it('resolves from OC config provider apiKey', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(
|
||||
ocPath,
|
||||
JSON.stringify({
|
||||
env: {},
|
||||
models: { providers: { anthropic: { apiKey: 'sk-oc-provider' } } },
|
||||
}),
|
||||
);
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-provider' });
|
||||
});
|
||||
|
||||
it('mosaic credential file wins over OC config', () => {
|
||||
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-wins\n');
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-loses' } }));
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: tmp,
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-wins' });
|
||||
});
|
||||
|
||||
it('gracefully falls back when OC config is missing', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-fallback';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-fallback' });
|
||||
});
|
||||
|
||||
it('skips redacted values in OC config', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: REDACTED_MARKER } }));
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient' });
|
||||
});
|
||||
|
||||
it('throws CredentialError when nothing resolves', () => {
|
||||
expect(() =>
|
||||
resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
}),
|
||||
).toThrow(CredentialError);
|
||||
});
|
||||
|
||||
it('supports task-level credential env var override', () => {
|
||||
process.env['CUSTOM_KEY'] = 'sk-custom';
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: join(tmp, 'nonexistent.json'),
|
||||
taskConfig: { credentials: { provider_key_env: 'CUSTOM_KEY' } },
|
||||
});
|
||||
expect(result).toEqual({ CUSTOM_KEY: 'sk-custom' });
|
||||
});
|
||||
|
||||
it('handles JSON5 OC config syntax', () => {
|
||||
const ocPath = join(tmp, 'openclaw.json');
|
||||
writeFileSync(
|
||||
ocPath,
|
||||
`{
|
||||
// OC config with JSON5 features
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'sk-json5-key',
|
||||
},
|
||||
}`,
|
||||
);
|
||||
const result = resolveCredentials('anthropic/claude-3', {
|
||||
credentialsDir: join(tmp, 'empty'),
|
||||
ocConfigPath: ocPath,
|
||||
});
|
||||
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-json5-key' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROVIDER_REGISTRY', () => {
|
||||
it('has entries for anthropic, openai, zai', () => {
|
||||
expect(Object.keys(PROVIDER_REGISTRY)).toEqual(['anthropic', 'openai', 'zai']);
|
||||
for (const meta of Object.values(PROVIDER_REGISTRY)) {
|
||||
expect(meta).toHaveProperty('credential_file');
|
||||
expect(meta).toHaveProperty('env_var');
|
||||
expect(meta).toHaveProperty('oc_env_key');
|
||||
expect(meta).toHaveProperty('oc_provider_path');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user