Skip to content

Commit adec0dc

Browse files
committed
fix: mask stored API keys and clear OAuth
1 parent eca6acf commit adec0dc

2 files changed

Lines changed: 117 additions & 6 deletions

File tree

src/commands/config/set.ts

Lines changed: 14 additions & 3 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',
@@ -95,22 +100,28 @@ export default defineCommand({
95100
const format = detectOutputFormat(config.output);
96101

97102
if (config.dryRun) {
98-
console.log(formatOutput({ would_set: { [resolvedKey]: value } }, format));
103+
console.log(formatOutput({
104+
would_set: { [resolvedKey]: valueForOutput(resolvedKey, value) },
105+
}, format));
99106
return;
100107
}
101108

102109
const existing = readConfigFile() as Record<string, unknown>;
103110
existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value;
104111

105-
// When API key changes, clear cached region so it gets re-detected
112+
// API key and OAuth are mutually exclusive. Clear OAuth and the cached
113+
// region so subsequent requests resolve the new key and re-detect its host.
106114
if (resolvedKey === 'api_key') {
115+
delete existing.oauth;
107116
delete existing.region;
108117
}
109118

110119
await writeConfigFile(existing);
111120

112121
if (!config.quiet) {
113-
console.log(formatOutput({ [resolvedKey]: existing[resolvedKey] }, format));
122+
console.log(formatOutput({
123+
[resolvedKey]: valueForOutput(resolvedKey, existing[resolvedKey]),
124+
}, format));
114125
}
115126
},
116127
});

test/commands/config/set.test.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1-
import { describe, it, expect, mock } from 'bun:test';
1+
import { beforeEach, describe, expect, it, mock } from 'bun:test';
22
import { default as setCommand } from '../../../src/commands/config/set';
33

4+
let storedConfig: Record<string, unknown> = {};
5+
let writtenConfig: Record<string, unknown> | undefined;
6+
const writeConfigFile = mock(async (data: Record<string, unknown>) => {
7+
writtenConfig = { ...data };
8+
});
9+
410
// Mock file I/O
511
mock.module('../../../src/config/loader', () => ({
6-
readConfigFile: () => ({}),
7-
writeConfigFile: mock(() => Promise.resolve()),
12+
readConfigFile: () => ({ ...storedConfig }),
13+
writeConfigFile,
814
}));
915

1016
describe('config set command', () => {
17+
beforeEach(() => {
18+
storedConfig = {};
19+
writtenConfig = undefined;
20+
writeConfigFile.mockClear();
21+
});
22+
1123
it('has correct name', () => {
1224
expect(setCommand.name).toBe('config set');
1325
});
@@ -135,4 +147,92 @@ describe('config set command', () => {
135147
}),
136148
).resolves.toBeUndefined();
137149
});
150+
151+
it('masks api_key in dry-run output', async () => {
152+
const apiKey = 'sk-test-secret-123456';
153+
let output = '';
154+
const originalLog = console.log;
155+
console.log = (message: string) => { output += message; };
156+
157+
try {
158+
await setCommand.execute({
159+
region: 'global',
160+
baseUrl: 'https://api.mmx.io',
161+
output: 'json',
162+
timeout: 10,
163+
verbose: false,
164+
quiet: false,
165+
noColor: true,
166+
yes: false,
167+
dryRun: true,
168+
nonInteractive: true,
169+
async: false,
170+
}, {
171+
key: 'api_key',
172+
value: apiKey,
173+
quiet: false,
174+
verbose: false,
175+
noColor: true,
176+
yes: false,
177+
dryRun: true,
178+
help: false,
179+
nonInteractive: true,
180+
async: false,
181+
});
182+
} finally {
183+
console.log = originalLog;
184+
}
185+
186+
expect(output).not.toContain(apiKey);
187+
expect(JSON.parse(output).would_set.api_key).toBe('sk-t...3456');
188+
expect(writeConfigFile).not.toHaveBeenCalled();
189+
});
190+
191+
it('removes OAuth and masks output when setting api_key', async () => {
192+
const apiKey = 'sk-test-secret-123456';
193+
storedConfig = {
194+
region: 'cn',
195+
oauth: {
196+
access_token: 'old-access-token',
197+
refresh_token: 'old-refresh-token',
198+
expires_at: '2099-01-01T00:00:00.000Z',
199+
},
200+
};
201+
let output = '';
202+
const originalLog = console.log;
203+
console.log = (message: string) => { output += message; };
204+
205+
try {
206+
await setCommand.execute({
207+
region: 'cn',
208+
baseUrl: 'https://api.mmx.io',
209+
output: 'json',
210+
timeout: 10,
211+
verbose: false,
212+
quiet: false,
213+
noColor: true,
214+
yes: false,
215+
dryRun: false,
216+
nonInteractive: true,
217+
async: false,
218+
}, {
219+
key: 'api_key',
220+
value: apiKey,
221+
quiet: false,
222+
verbose: false,
223+
noColor: true,
224+
yes: false,
225+
dryRun: false,
226+
help: false,
227+
nonInteractive: true,
228+
async: false,
229+
});
230+
} finally {
231+
console.log = originalLog;
232+
}
233+
234+
expect(writtenConfig).toEqual({ api_key: apiKey });
235+
expect(output).not.toContain(apiKey);
236+
expect(JSON.parse(output).api_key).toBe('sk-t...3456');
237+
});
138238
});

0 commit comments

Comments
 (0)