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'); } }); });