Skip to content

Commit dc9b7e2

Browse files
committed
fix: mask stored API keys and clear OAuth
1 parent ab29137 commit dc9b7e2

2 files changed

Lines changed: 161 additions & 5 deletions

File tree

src/commands/config/set.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
44
import { formatOutput, detectOutputFormat } from '../../output/formatter';
55
import { readConfigFile, writeConfigFile } from '../../config/loader';
6+
import { maskToken } from '../../utils/token';
67
import type { Config } from '../../config/schema';
78
import type { GlobalFlags } from '../../types/flags';
89

@@ -16,6 +17,10 @@ const KEY_ALIASES: Record<string, string> = {
1617
'default-music-model': 'default_music_model',
1718
};
1819

20+
function valueForOutput(key: string, value: unknown): unknown {
21+
return key === 'api_key' ? maskToken(String(value)) : value;
22+
}
23+
1924
export default defineCommand({
2025
name: 'config set',
2126
description: 'Set a config value',
@@ -53,6 +58,14 @@ export default defineCommand({
5358
);
5459
}
5560

61+
const valueToSet = resolvedKey === 'api_key' ? value.trim() : value;
62+
if (resolvedKey === 'api_key' && valueToSet.length === 0) {
63+
throw new CLIError(
64+
'Invalid api_key. The value must not be empty.',
65+
ExitCode.USAGE,
66+
);
67+
}
68+
5669
// Validate specific values
5770
if (resolvedKey === 'region' && !['global', 'cn'].includes(value)) {
5871
throw new CLIError(
@@ -95,22 +108,28 @@ export default defineCommand({
95108
const format = detectOutputFormat(config.output);
96109

97110
if (config.dryRun) {
98-
console.log(formatOutput({ would_set: { [resolvedKey]: value } }, format));
111+
console.log(formatOutput({
112+
would_set: { [resolvedKey]: valueForOutput(resolvedKey, valueToSet) },
113+
}, format));
99114
return;
100115
}
101116

102117
const existing = readConfigFile() as Record<string, unknown>;
103-
existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value;
118+
existing[resolvedKey] = resolvedKey === 'timeout' ? Number(valueToSet) : valueToSet;
104119

105-
// When API key changes, clear cached region so it gets re-detected
120+
// API key and OAuth are mutually exclusive. Clear OAuth and the cached
121+
// region so subsequent requests resolve the new key and re-detect its host.
106122
if (resolvedKey === 'api_key') {
123+
delete existing.oauth;
107124
delete existing.region;
108125
}
109126

110127
await writeConfigFile(existing);
111128

112129
if (!config.quiet) {
113-
console.log(formatOutput({ [resolvedKey]: existing[resolvedKey] }, format));
130+
console.log(formatOutput({
131+
[resolvedKey]: valueForOutput(resolvedKey, existing[resolvedKey]),
132+
}, format));
114133
}
115134
},
116135
});

test/commands/config/set.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, it, expect } from 'bun:test';
1+
import { describe, expect, it, spyOn } from 'bun:test';
22
import { default as setCommand } from '../../../src/commands/config/set';
3+
import * as configLoader from '../../../src/config/loader';
34

45
describe('config set command', () => {
56
it('has correct name', () => {
@@ -129,4 +130,140 @@ describe('config set command', () => {
129130
}),
130131
).resolves.toBeUndefined();
131132
});
133+
134+
it('masks api_key in dry-run output', async () => {
135+
const apiKey = 'sk-test-secret-123456';
136+
const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockResolvedValue();
137+
let output = '';
138+
const originalLog = console.log;
139+
console.log = (message: string) => { output += message; };
140+
141+
try {
142+
await setCommand.execute({
143+
region: 'global',
144+
baseUrl: 'https://api.mmx.io',
145+
output: 'json',
146+
timeout: 10,
147+
verbose: false,
148+
quiet: false,
149+
noColor: true,
150+
yes: false,
151+
dryRun: true,
152+
nonInteractive: true,
153+
async: false,
154+
}, {
155+
key: 'api_key',
156+
value: apiKey,
157+
quiet: false,
158+
verbose: false,
159+
noColor: true,
160+
yes: false,
161+
dryRun: true,
162+
help: false,
163+
nonInteractive: true,
164+
async: false,
165+
});
166+
} finally {
167+
console.log = originalLog;
168+
writeConfigFile.mockRestore();
169+
}
170+
171+
expect(output).not.toContain(apiKey);
172+
expect(JSON.parse(output).would_set.api_key).toBe('sk-t...3456');
173+
expect(writeConfigFile).not.toHaveBeenCalled();
174+
});
175+
176+
it('removes OAuth and masks output when setting api_key', async () => {
177+
const apiKey = 'sk-test-secret-123456';
178+
const readConfigFile = spyOn(configLoader, 'readConfigFile').mockReturnValue({
179+
region: 'cn',
180+
oauth: {
181+
access_token: 'old-access-token',
182+
refresh_token: 'old-refresh-token',
183+
expires_at: '2099-01-01T00:00:00.000Z',
184+
},
185+
});
186+
let writtenConfig: Record<string, unknown> | undefined;
187+
const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockImplementation(async (data) => {
188+
writtenConfig = { ...data };
189+
});
190+
let output = '';
191+
const originalLog = console.log;
192+
console.log = (message: string) => { output += message; };
193+
194+
try {
195+
await setCommand.execute({
196+
region: 'cn',
197+
baseUrl: 'https://api.mmx.io',
198+
output: 'json',
199+
timeout: 10,
200+
verbose: false,
201+
quiet: false,
202+
noColor: true,
203+
yes: false,
204+
dryRun: false,
205+
nonInteractive: true,
206+
async: false,
207+
}, {
208+
key: 'api_key',
209+
value: ` ${apiKey} `,
210+
quiet: false,
211+
verbose: false,
212+
noColor: true,
213+
yes: false,
214+
dryRun: false,
215+
help: false,
216+
nonInteractive: true,
217+
async: false,
218+
});
219+
} finally {
220+
console.log = originalLog;
221+
readConfigFile.mockRestore();
222+
writeConfigFile.mockRestore();
223+
}
224+
225+
expect(writtenConfig).toEqual({ api_key: apiKey });
226+
expect(output).not.toContain(apiKey);
227+
expect(JSON.parse(output).api_key).toBe('sk-t...3456');
228+
});
229+
230+
it('rejects empty api_key values without changing existing credentials', async () => {
231+
const readConfigFile = spyOn(configLoader, 'readConfigFile');
232+
const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockResolvedValue();
233+
234+
try {
235+
for (const value of ['', ' ']) {
236+
await expect(setCommand.execute({
237+
region: 'cn',
238+
baseUrl: 'https://api.mmx.io',
239+
output: 'json',
240+
timeout: 10,
241+
verbose: false,
242+
quiet: false,
243+
noColor: true,
244+
yes: false,
245+
dryRun: false,
246+
nonInteractive: true,
247+
async: false,
248+
}, {
249+
key: 'api_key',
250+
value,
251+
quiet: false,
252+
verbose: false,
253+
noColor: true,
254+
yes: false,
255+
dryRun: false,
256+
help: false,
257+
nonInteractive: true,
258+
async: false,
259+
})).rejects.toThrow('must not be empty');
260+
}
261+
} finally {
262+
readConfigFile.mockRestore();
263+
writeConfigFile.mockRestore();
264+
}
265+
266+
expect(readConfigFile).not.toHaveBeenCalled();
267+
expect(writeConfigFile).not.toHaveBeenCalled();
268+
});
132269
});

0 commit comments

Comments
 (0)