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:
pomelo-nwu 2026-05-20 11:17:07 +08:00
parent 9f45a7536b
commit f31224bac1
3 changed files with 162 additions and 9 deletions

View file

@ -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),
);
}
}

View file

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

View file

@ -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}` },