Skip to content

Commit 01beb99

Browse files
committed
feat: auto-discover MiniMax credentials from OpenClaw
When no API key is configured, the CLI now checks OpenClaw's auth-profiles.json and openclaw.json for existing MiniMax credentials before prompting the user. This lets users skip re-entering their API key if they already have one configured in OpenClaw.
1 parent 813ff4c commit 01beb99

File tree

3 files changed

+368
-0
lines changed

3 files changed

+368
-0
lines changed

src/auth/discover.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { readFileSync, existsSync } from 'fs';
2+
import { homedir } from 'os';
3+
import { join } from 'path';
4+
5+
export interface DiscoveredCredential {
6+
key: string;
7+
source: string;
8+
region?: 'global' | 'cn';
9+
}
10+
11+
const OPENCLAW_AUTH_PROFILES_PATH = join(
12+
'.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json',
13+
);
14+
const OPENCLAW_CONFIG_PATH = join('.openclaw', 'openclaw.json');
15+
16+
const API_KEY_PROFILE_IDS: Array<{ id: string; region: 'global' | 'cn' }> = [
17+
{ id: 'minimax:global', region: 'global' },
18+
{ id: 'minimax:cn', region: 'cn' },
19+
];
20+
21+
function tryReadJson(filePath: string): unknown {
22+
try {
23+
if (!existsSync(filePath)) return null;
24+
return JSON.parse(readFileSync(filePath, 'utf-8'));
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
function probeAuthProfiles(home: string): DiscoveredCredential | null {
31+
const filePath = join(home, OPENCLAW_AUTH_PROFILES_PATH);
32+
const data = tryReadJson(filePath);
33+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
34+
35+
const store = data as Record<string, unknown>;
36+
if (typeof store.profiles !== 'object' || !store.profiles) return null;
37+
const profiles = store.profiles as Record<string, unknown>;
38+
39+
// 1. Check API key profiles (preferred)
40+
for (const { id, region } of API_KEY_PROFILE_IDS) {
41+
const profile = profiles[id];
42+
if (!profile || typeof profile !== 'object') continue;
43+
const p = profile as Record<string, unknown>;
44+
if (p.type === 'api_key' && typeof p.key === 'string' && p.key.length > 0) {
45+
return { key: p.key, source: 'OpenClaw auth-profiles.json', region };
46+
}
47+
}
48+
49+
// 2. Check OAuth profiles (access field holds the API key)
50+
for (const [id, profile] of Object.entries(profiles)) {
51+
if (!id.startsWith('minimax-portal:')) continue;
52+
if (!profile || typeof profile !== 'object') continue;
53+
const p = profile as Record<string, unknown>;
54+
if (p.type === 'oauth' && typeof p.access === 'string' && p.access.length > 0) {
55+
return { key: p.access, source: 'OpenClaw auth-profiles.json' };
56+
}
57+
}
58+
59+
return null;
60+
}
61+
62+
function probeOpenClawConfig(home: string): DiscoveredCredential | null {
63+
const filePath = join(home, OPENCLAW_CONFIG_PATH);
64+
const data = tryReadJson(filePath);
65+
if (!data || typeof data !== 'object') return null;
66+
67+
const models = (data as Record<string, unknown>).models;
68+
if (!models || typeof models !== 'object') return null;
69+
70+
const providers = (models as Record<string, unknown>).providers;
71+
if (!providers || typeof providers !== 'object') return null;
72+
73+
const p = providers as Record<string, unknown>;
74+
for (const providerKey of ['minimax', 'minimax-portal']) {
75+
const entry = p[providerKey];
76+
if (!entry || typeof entry !== 'object') continue;
77+
const apiKey = (entry as Record<string, unknown>).apiKey;
78+
if (typeof apiKey === 'string' && apiKey.length > 0) {
79+
return { key: apiKey, source: 'OpenClaw config' };
80+
}
81+
}
82+
83+
return null;
84+
}
85+
86+
export function discoverExternalCredential(homeDir?: string): DiscoveredCredential | null {
87+
const home = homeDir ?? homedir();
88+
89+
return probeAuthProfiles(home) ?? probeOpenClawConfig(home) ?? null;
90+
}

src/auth/setup.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isInteractive } from '../utils/env';
55
import { maskToken } from '../utils/token';
66
import { CLIError } from '../errors/base';
77
import { ExitCode } from '../errors/codes';
8+
import { discoverExternalCredential } from './discover';
89

910
export async function ensureApiKey(config: Config): Promise<void> {
1011
if (config.apiKey || config.fileApiKey) return;
@@ -23,6 +24,38 @@ export async function ensureApiKey(config: Config): Promise<void> {
2324
}
2425
}
2526

27+
// Try to discover credentials from external tools (e.g. OpenClaw)
28+
if (!key) {
29+
const discovered = discoverExternalCredential();
30+
if (discovered) {
31+
const interactive = isInteractive({ nonInteractive: config.nonInteractive });
32+
let accepted = !interactive;
33+
34+
if (interactive) {
35+
accepted = !!(await promptConfirm({
36+
message: `Found MiniMax API key (${maskToken(discovered.key)}) in ${discovered.source}. Import it?`,
37+
}));
38+
}
39+
40+
if (accepted) {
41+
const data: Record<string, unknown> = {
42+
...(readConfigFile() as Record<string, unknown>),
43+
api_key: discovered.key,
44+
};
45+
if (discovered.region) data.region = discovered.region;
46+
await writeConfigFile(data);
47+
config.fileApiKey = discovered.key;
48+
if (discovered.region) {
49+
config.region = discovered.region;
50+
config.needsRegionDetection = false;
51+
}
52+
const path = config.configPath ?? '~/.mmx/config.json';
53+
process.stderr.write(`Imported API key from ${discovered.source}${path}\n`);
54+
return;
55+
}
56+
}
57+
}
58+
2659
if (!key) {
2760
if (!isInteractive({ nonInteractive: config.nonInteractive })) {
2861
throw new CLIError(

test/auth/discover.test.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2+
import { discoverExternalCredential } from '../../src/auth/discover';
3+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
4+
import { join } from 'path';
5+
import { tmpdir } from 'os';
6+
7+
describe('discoverExternalCredential', () => {
8+
const testDir = join(tmpdir(), `mmx-discover-test-${Date.now()}`);
9+
const originalHome = process.env.HOME;
10+
11+
const authProfilesDir = join(testDir, '.openclaw', 'agents', 'main', 'agent');
12+
const authProfilesPath = join(authProfilesDir, 'auth-profiles.json');
13+
const openclawConfigPath = join(testDir, '.openclaw', 'openclaw.json');
14+
15+
beforeEach(() => {
16+
mkdirSync(authProfilesDir, { recursive: true });
17+
process.env.HOME = testDir;
18+
});
19+
20+
afterEach(() => {
21+
process.env.HOME = originalHome;
22+
if (existsSync(testDir)) {
23+
rmSync(testDir, { recursive: true, force: true });
24+
}
25+
});
26+
27+
it('returns null when no OpenClaw directory exists', () => {
28+
rmSync(join(testDir, '.openclaw'), { recursive: true, force: true });
29+
const result = discoverExternalCredential(testDir);
30+
expect(result).toBeNull();
31+
});
32+
33+
it('discovers API key from minimax:global profile', () => {
34+
writeFileSync(authProfilesPath, JSON.stringify({
35+
version: 1,
36+
profiles: {
37+
'minimax:global': {
38+
type: 'api_key',
39+
provider: 'minimax',
40+
key: 'sk-test-global-key',
41+
},
42+
},
43+
}));
44+
45+
const result = discoverExternalCredential(testDir);
46+
expect(result).not.toBeNull();
47+
expect(result!.key).toBe('sk-test-global-key');
48+
expect(result!.region).toBe('global');
49+
expect(result!.source).toBe('OpenClaw auth-profiles.json');
50+
});
51+
52+
it('discovers API key from minimax:cn profile', () => {
53+
writeFileSync(authProfilesPath, JSON.stringify({
54+
version: 1,
55+
profiles: {
56+
'minimax:cn': {
57+
type: 'api_key',
58+
provider: 'minimax',
59+
key: 'sk-test-cn-key',
60+
},
61+
},
62+
}));
63+
64+
const result = discoverExternalCredential(testDir);
65+
expect(result).not.toBeNull();
66+
expect(result!.key).toBe('sk-test-cn-key');
67+
expect(result!.region).toBe('cn');
68+
});
69+
70+
it('extracts access field from OAuth profile as API key', () => {
71+
writeFileSync(authProfilesPath, JSON.stringify({
72+
version: 1,
73+
profiles: {
74+
'minimax-portal:default': {
75+
type: 'oauth',
76+
provider: 'minimax-portal',
77+
access: 'sk-oauth-access-key',
78+
refresh: 'refresh-token',
79+
expires: Date.now() + 3600000,
80+
},
81+
},
82+
}));
83+
84+
const result = discoverExternalCredential(testDir);
85+
expect(result).not.toBeNull();
86+
expect(result!.key).toBe('sk-oauth-access-key');
87+
expect(result!.source).toBe('OpenClaw auth-profiles.json');
88+
});
89+
90+
it('prefers API key profile over OAuth profile', () => {
91+
writeFileSync(authProfilesPath, JSON.stringify({
92+
version: 1,
93+
profiles: {
94+
'minimax:global': {
95+
type: 'api_key',
96+
provider: 'minimax',
97+
key: 'sk-api-key',
98+
},
99+
'minimax-portal:default': {
100+
type: 'oauth',
101+
provider: 'minimax-portal',
102+
access: 'sk-oauth-key',
103+
refresh: 'refresh',
104+
expires: Date.now() + 3600000,
105+
},
106+
},
107+
}));
108+
109+
const result = discoverExternalCredential(testDir);
110+
expect(result).not.toBeNull();
111+
expect(result!.key).toBe('sk-api-key');
112+
});
113+
114+
it('prefers global over cn when both present', () => {
115+
writeFileSync(authProfilesPath, JSON.stringify({
116+
version: 1,
117+
profiles: {
118+
'minimax:global': {
119+
type: 'api_key',
120+
provider: 'minimax',
121+
key: 'sk-global',
122+
},
123+
'minimax:cn': {
124+
type: 'api_key',
125+
provider: 'minimax',
126+
key: 'sk-cn',
127+
},
128+
},
129+
}));
130+
131+
const result = discoverExternalCredential(testDir);
132+
expect(result).not.toBeNull();
133+
expect(result!.key).toBe('sk-global');
134+
expect(result!.region).toBe('global');
135+
});
136+
137+
it('discovers API key from openclaw.json config', () => {
138+
writeFileSync(openclawConfigPath, JSON.stringify({
139+
models: {
140+
providers: {
141+
minimax: {
142+
apiKey: 'sk-config-key',
143+
},
144+
},
145+
},
146+
}));
147+
148+
const result = discoverExternalCredential(testDir);
149+
expect(result).not.toBeNull();
150+
expect(result!.key).toBe('sk-config-key');
151+
expect(result!.source).toBe('OpenClaw config');
152+
});
153+
154+
it('prefers auth-profiles.json over openclaw.json', () => {
155+
writeFileSync(authProfilesPath, JSON.stringify({
156+
version: 1,
157+
profiles: {
158+
'minimax:global': {
159+
type: 'api_key',
160+
provider: 'minimax',
161+
key: 'sk-from-profiles',
162+
},
163+
},
164+
}));
165+
writeFileSync(openclawConfigPath, JSON.stringify({
166+
models: {
167+
providers: {
168+
minimax: {
169+
apiKey: 'sk-from-config',
170+
},
171+
},
172+
},
173+
}));
174+
175+
const result = discoverExternalCredential(testDir);
176+
expect(result).not.toBeNull();
177+
expect(result!.key).toBe('sk-from-profiles');
178+
expect(result!.source).toBe('OpenClaw auth-profiles.json');
179+
});
180+
181+
it('returns null for corrupted JSON', () => {
182+
writeFileSync(authProfilesPath, '{not valid json!!!');
183+
const result = discoverExternalCredential(testDir);
184+
expect(result).toBeNull();
185+
});
186+
187+
it('returns null for wrong schema (missing profiles key)', () => {
188+
writeFileSync(authProfilesPath, JSON.stringify({
189+
version: 1,
190+
something_else: {},
191+
}));
192+
193+
const result = discoverExternalCredential(testDir);
194+
expect(result).toBeNull();
195+
});
196+
197+
it('skips profiles with unknown type', () => {
198+
writeFileSync(authProfilesPath, JSON.stringify({
199+
version: 1,
200+
profiles: {
201+
'minimax:global': {
202+
type: 'saml',
203+
provider: 'minimax',
204+
},
205+
},
206+
}));
207+
208+
const result = discoverExternalCredential(testDir);
209+
expect(result).toBeNull();
210+
});
211+
212+
it('skips API key profile with empty key', () => {
213+
writeFileSync(authProfilesPath, JSON.stringify({
214+
version: 1,
215+
profiles: {
216+
'minimax:global': {
217+
type: 'api_key',
218+
provider: 'minimax',
219+
key: '',
220+
},
221+
},
222+
}));
223+
224+
const result = discoverExternalCredential(testDir);
225+
expect(result).toBeNull();
226+
});
227+
228+
it('skips OAuth profile with empty access', () => {
229+
writeFileSync(authProfilesPath, JSON.stringify({
230+
version: 1,
231+
profiles: {
232+
'minimax-portal:default': {
233+
type: 'oauth',
234+
provider: 'minimax-portal',
235+
access: '',
236+
refresh: 'refresh',
237+
expires: Date.now(),
238+
},
239+
},
240+
}));
241+
242+
const result = discoverExternalCredential(testDir);
243+
expect(result).toBeNull();
244+
});
245+
});

0 commit comments

Comments
 (0)