mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-23 12:44:02 +00:00
fix(auth): tenth round of PR #4287 review fixes
Critical (both from the previous round's own changes): - WebViewProvider.handleAuthInteractive: restoreSettingsSnapshot → writeSettings can throw (EPERM on Windows renameSync / disk full / EACCES). Both rollback call sites are now routed through a local safeRollback() that try/catches and logs, so a rollback failure can never (a) re-throw out of the else-branch into the outer catch and trigger a second rollback that skips the error message, nor (b) throw out of the catch-branch and leave the webview auth dialog hanging with no feedback. - provider-config.providerMatchesCredentials: the new envKey-throw console.warn logged the full baseUrl, which can embed credentials (https://user:sk-secret@host). Now logs only new URL(baseUrl).hostname (with an [invalid] fallback) and err.message, matching the sanitization WebViewProvider already uses. Tests: - WebViewProvider.test: new 'credential rollback' describe with three cases — (1) authState!==true after reconnect → restoreSettingsSnapshot called with the snapshot, (2) authState===true → restore NOT called, (3) restore throws (EPERM) → handleAuthInteractive still resolves and the authError message is still sent. Hoisted mocks extended with applyProviderInstallPlanToFile / snapshotSettingsForRollback / restoreSettingsSnapshot refs so the scenario is controllable. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
9f45a7536b
commit
f31224bac1
3 changed files with 162 additions and 9 deletions
|
|
@ -368,11 +368,20 @@ export function providerMatchesCredentials(
|
|||
} catch (err) {
|
||||
// A throw here is a programming error in the provider's envKey fn,
|
||||
// not an expected "no match" — surface it so a custom provider
|
||||
// silently vanishing from /doctor / system-info has a trace.
|
||||
// silently vanishing from /doctor / system-info has a trace. Log
|
||||
// only the host (a custom baseUrl can embed credentials like
|
||||
// https://user:sk-secret@host) and the error message, not the raw
|
||||
// error object / full URL.
|
||||
let safeHost: string;
|
||||
try {
|
||||
safeHost = new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
safeHost = '[invalid]';
|
||||
}
|
||||
// eslint-disable-next-line no-console -- diagnostic for a misconfigured provider
|
||||
console.warn(
|
||||
`[providerMatchesCredentials] envKey(${proto}, ${baseUrl}) threw; skipping this protocol:`,
|
||||
err,
|
||||
`[providerMatchesCredentials] envKey(${proto}, ${safeHost}) threw; skipping this protocol:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ const {
|
|||
mockWriteCodingPlanConfig,
|
||||
mockWriteModelProvidersConfig,
|
||||
mockClearPersistedAuth,
|
||||
mockApplyProviderInstallPlanToFile,
|
||||
mockSnapshotSettingsForRollback,
|
||||
mockRestoreSettingsSnapshot,
|
||||
slashCommandNotificationCallbackRef,
|
||||
endTurnCallbackRef,
|
||||
streamChunkCallbackRef,
|
||||
|
|
@ -77,6 +80,11 @@ const {
|
|||
mockWriteCodingPlanConfig: vi.fn(() => ({})),
|
||||
mockWriteModelProvidersConfig: vi.fn(),
|
||||
mockClearPersistedAuth: vi.fn(),
|
||||
mockApplyProviderInstallPlanToFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockSnapshotSettingsForRollback: vi.fn<() => Record<string, unknown> | null>(
|
||||
() => null,
|
||||
),
|
||||
mockRestoreSettingsSnapshot: vi.fn(),
|
||||
slashCommandNotificationCallbackRef: {
|
||||
current: undefined as
|
||||
| ((event: {
|
||||
|
|
@ -166,9 +174,9 @@ vi.mock('../../services/settingsWriter.js', () => ({
|
|||
writeModelProvidersConfig: mockWriteModelProvidersConfig,
|
||||
readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode,
|
||||
clearPersistedAuth: mockClearPersistedAuth,
|
||||
applyProviderInstallPlanToFile: vi.fn().mockResolvedValue(undefined),
|
||||
snapshotSettingsForRollback: vi.fn().mockReturnValue(null),
|
||||
restoreSettingsSnapshot: vi.fn(),
|
||||
applyProviderInstallPlanToFile: mockApplyProviderInstallPlanToFile,
|
||||
snapshotSettingsForRollback: mockSnapshotSettingsForRollback,
|
||||
restoreSettingsSnapshot: mockRestoreSettingsSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/qwenAgentManager.js', () => ({
|
||||
|
|
@ -1685,3 +1693,125 @@ describe('Notification & dot indicator', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebViewProvider.handleAuthInteractive credential rollback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSnapshotSettingsForRollback.mockReturnValue(null);
|
||||
});
|
||||
|
||||
// Minimal real-ish provider config + inputs so the real buildInstallPlan
|
||||
// (core is not mocked beyond Storage) produces a valid plan.
|
||||
const providerConfig = {
|
||||
id: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
envKey: 'DEEPSEEK_API_KEY',
|
||||
models: [{ id: 'deepseek-v4-flash' }],
|
||||
modelNamePrefix: 'DeepSeek',
|
||||
} as unknown as Parameters<WebViewProvider['handleAuthInteractive']>[0];
|
||||
const inputs = {
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'sk-bad-key',
|
||||
modelIds: ['deepseek-v4-flash'],
|
||||
} as unknown as Parameters<WebViewProvider['handleAuthInteractive']>[1];
|
||||
|
||||
function makeProvider() {
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
// Avoid touching the real webview pipe.
|
||||
(
|
||||
provider as unknown as { sendMessageToWebView: () => void }
|
||||
).sendMessageToWebView = vi.fn();
|
||||
return provider;
|
||||
}
|
||||
|
||||
it('restores the snapshot when the reconnect leaves authState !== true', async () => {
|
||||
const snapshot = { env: { OPENAI_API_KEY: 'sk-old' } };
|
||||
mockSnapshotSettingsForRollback.mockReturnValue(snapshot);
|
||||
|
||||
const provider = makeProvider();
|
||||
// doInitializeAgentConnection runs but the backend rejects the key, so
|
||||
// authState stays false.
|
||||
(
|
||||
provider as unknown as {
|
||||
doInitializeAgentConnection: () => Promise<void>;
|
||||
authState: boolean;
|
||||
}
|
||||
).doInitializeAgentConnection = vi.fn(async () => {
|
||||
(provider as unknown as { authState: boolean }).authState = false;
|
||||
});
|
||||
|
||||
await (
|
||||
provider as unknown as {
|
||||
handleAuthInteractive: (c: unknown, i: unknown) => Promise<void>;
|
||||
}
|
||||
).handleAuthInteractive(providerConfig, inputs);
|
||||
|
||||
expect(mockApplyProviderInstallPlanToFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestoreSettingsSnapshot).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it('does NOT restore when the reconnect authenticates (authState === true)', async () => {
|
||||
mockSnapshotSettingsForRollback.mockReturnValue({
|
||||
env: { OPENAI_API_KEY: 'sk-old' },
|
||||
});
|
||||
|
||||
const provider = makeProvider();
|
||||
(
|
||||
provider as unknown as {
|
||||
doInitializeAgentConnection: () => Promise<void>;
|
||||
authState: boolean;
|
||||
}
|
||||
).doInitializeAgentConnection = vi.fn(async () => {
|
||||
(provider as unknown as { authState: boolean }).authState = true;
|
||||
});
|
||||
|
||||
await (
|
||||
provider as unknown as {
|
||||
handleAuthInteractive: (c: unknown, i: unknown) => Promise<void>;
|
||||
}
|
||||
).handleAuthInteractive(providerConfig, inputs);
|
||||
|
||||
expect(mockRestoreSettingsSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a rollback write failure so the authError message still sends', async () => {
|
||||
mockSnapshotSettingsForRollback.mockReturnValue({
|
||||
env: { OPENAI_API_KEY: 'sk-old' },
|
||||
});
|
||||
// restore itself throws (e.g. EPERM on Windows renameSync).
|
||||
mockRestoreSettingsSnapshot.mockImplementation(() => {
|
||||
throw new Error('EPERM: rename failed');
|
||||
});
|
||||
|
||||
const provider = makeProvider();
|
||||
const sendToWebView = (
|
||||
provider as unknown as { sendMessageToWebView: ReturnType<typeof vi.fn> }
|
||||
).sendMessageToWebView;
|
||||
(
|
||||
provider as unknown as {
|
||||
doInitializeAgentConnection: () => Promise<void>;
|
||||
authState: boolean;
|
||||
}
|
||||
).doInitializeAgentConnection = vi.fn(async () => {
|
||||
(provider as unknown as { authState: boolean }).authState = false;
|
||||
});
|
||||
|
||||
await expect(
|
||||
(
|
||||
provider as unknown as {
|
||||
handleAuthInteractive: (c: unknown, i: unknown) => Promise<void>;
|
||||
}
|
||||
).handleAuthInteractive(providerConfig, inputs),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// The rollback throw must not prevent the user-facing authError.
|
||||
expect(sendToWebView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'authError' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1335,6 +1335,19 @@ export class WebViewProvider {
|
|||
// without this a rejected key would persist and every VS Code restart
|
||||
// would keep retrying it.
|
||||
const rollbackSnapshot = snapshotSettingsForRollback();
|
||||
// restoreSettingsSnapshot → writeSettings can itself throw (EPERM on
|
||||
// Windows renameSync, disk full, EACCES). Never let a rollback failure
|
||||
// mask the original auth error or skip the user-facing error message.
|
||||
const safeRollback = () => {
|
||||
try {
|
||||
restoreSettingsSnapshot(rollbackSnapshot);
|
||||
} catch (rollbackErr) {
|
||||
console.error(
|
||||
'[WebViewProvider] settings rollback failed:',
|
||||
rollbackErr,
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Use core's buildInstallPlan to create a standardized install plan,
|
||||
// then apply it via the VSCode settings adapter.
|
||||
|
|
@ -1366,7 +1379,7 @@ export class WebViewProvider {
|
|||
} else {
|
||||
// Auth failed against the live backend — roll the bad credentials
|
||||
// back off disk so a restart doesn't keep retrying them.
|
||||
restoreSettingsSnapshot(rollbackSnapshot);
|
||||
safeRollback();
|
||||
this.sendMessageToWebView({
|
||||
type: 'authError',
|
||||
data: {
|
||||
|
|
@ -1381,8 +1394,9 @@ export class WebViewProvider {
|
|||
// A throw can land here after the plan committed but before/while
|
||||
// reconnecting — restore the snapshot so partial/bad state doesn't
|
||||
// linger. (Redundant but harmless if the plan's own rollback already
|
||||
// ran: it just rewrites the same pre-state.)
|
||||
restoreSettingsSnapshot(rollbackSnapshot);
|
||||
// ran: it just rewrites the same pre-state.) safeRollback swallows a
|
||||
// rollback throw so it can't pre-empt the authError message below.
|
||||
safeRollback();
|
||||
this.sendMessageToWebView({
|
||||
type: 'authError',
|
||||
data: { message: `Configuration failed: ${errorMsg}` },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue