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.
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
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)`,
|
|
);
|
|
}
|