diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts index 368d88e53..10ed8057e 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -125,6 +125,7 @@ function makeDeps( adoptInterpreter: async () => {}, saveState: () => {}, notify: async () => {}, + showReauthPrompt: async () => {}, showError: async () => {}, showSuccess: async () => {}, reportEnvironment: { @@ -572,6 +573,74 @@ describe("PythonSetupEnvironmentSetup.setup", () => { }); }); + it("prompts re-login (no report) when the CLI aborts telling the user to re-authenticate", async () => { + const shownErrors: { + message: string; + action?: PythonSetupErrorAction; + }[] = []; + let reauthPrompts = 0; + const telemetry = makeTelemetryRecorder(); + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + // A no-result abort whose stderr carries the reauth signal. + cli: makeCli({ + reject: new Error( + "CLI did not return valid JSON: Unexpected end of JSON input\n" + + "Error: the refresh token is invalid. To reauthenticate, " + + "run: databricks auth login --profile dev" + ), + }), + recordSetupAttempt: telemetry.recordSetupAttempt, + showReauthPrompt: async () => { + reauthPrompts++; + }, + showError: async (message, _detail, action) => { + shownErrors.push({message, action}); + }, + }) + ); + + await setup.setup(); + + expect(setup.ready).to.equal(false); + // The re-login prompt replaces the hard error + report action entirely. + expect(reauthPrompts).to.equal(1); + expect(shownErrors).to.have.length(0); + expect(telemetry.results[0]).to.include({ + outcome: "not_started", + reportOffered: false, + }); + }); + + it("reports a spawn/parse defect (not re-login) when the abort has no reauth signal", async () => { + const shown: {action?: PythonSetupErrorAction}[] = []; + let reauthPrompts = 0; + const telemetry = makeTelemetryRecorder(); + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + // A no-result abort with no auth signal (e.g. offline / crash): + // it must stay on the report path, not be mislabeled as expiry. + cli: makeCli({reject: new Error("spawn databricks ENOENT")}), + recordSetupAttempt: telemetry.recordSetupAttempt, + showReauthPrompt: async () => { + reauthPrompts++; + }, + showError: async (_m, _detail, action) => { + shown.push({action}); + }, + }) + ); + + await setup.setup(); + + expect(reauthPrompts).to.equal(0); + expect(shown[0].action?.label).to.equal("Report this problem"); + expect(telemetry.results[0]).to.include({ + outcome: "not_started", + reportOffered: true, + }); + }); + it("handles a non-Error rejection without throwing on the spawn path", async () => { const shown: {message: string; actions?: PythonSetupErrorAction[]}[] = []; diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index 684146cbd..27300f680 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -26,6 +26,7 @@ import { reportRepoForResult, ReportEnvironment, } from "../utils/reportSetupIssue"; +import {isReauthRequiredError} from "../utils/authErrors"; import {SetupLocalInvocation} from "../utils/setupLocalArgs"; import { PythonSetupAttempt, @@ -138,6 +139,15 @@ export interface PythonSetupSetupDeps { */ notify: (message: string) => Promise; + /** + * Prompt the user to re-authenticate when setup-local aborted because the + * profile's session expired (see {@link isReauthRequiredError}). A plain + * warning with a "Login" action that runs the extension's re-auth flow — no + * error styling, no log reveal, and no "Report this problem": an expired + * session is an expected, self-service condition, not a defect to report. + */ + showReauthPrompt: () => Promise; + /** * Shows the mapped, user-facing copy — not raw CLI text — with a "Show Logs" * action that reveals the setup output channel. `detail`, when given, is @@ -397,6 +407,15 @@ export class PythonSetupEnvironmentSetup implements Disposable { // non-Error rejection so `.message` is never undefined (the redactor // would throw on it, swallowing the original failure). const message = e instanceof Error ? e.message : String(e); + // An expired session is expected, not a defect: route it to a + // re-login prompt, not the hard error + "Report this problem". A + // positive gate — anything unmatched (real defect, network blip) + // falls through to the report path. + if (isReauthRequiredError(message)) { + reportResult({outcome: "not_started", reportOffered: false}); + this.present(this.deps.showReauthPrompt()); + return; + } const reportAction = buildExtensionFailureReportAction(reportEnv, { phase: "spawn", message, diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index 5e94c3c4c..6dbfc1aa5 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -957,3 +957,82 @@ describe("makePythonSetupDeps showSuccess", () => { expect(shown).to.equal(2); }); }); + +describe("makePythonSetupDeps showReauthPrompt", () => { + let originalShowWarning: typeof window.showWarningMessage; + let originalExecuteCommand: typeof commands.executeCommand; + let shownWith: {message: string; actions: string[]}[]; + let executed: string[]; + let reply: string | undefined; + + beforeEach(() => { + originalShowWarning = window.showWarningMessage; + originalExecuteCommand = commands.executeCommand; + shownWith = []; + executed = []; + reply = undefined; + ( + window as unknown as {showWarningMessage: unknown} + ).showWarningMessage = async ( + message: string, + ...actions: string[] + ) => { + shownWith.push({message, actions}); + return reply; + }; + (commands as unknown as {executeCommand: unknown}).executeCommand = + async (command: string) => { + executed.push(command); + return undefined; + }; + }); + + afterEach(() => { + ( + window as unknown as {showWarningMessage: unknown} + ).showWarningMessage = originalShowWarning; + (commands as unknown as {executeCommand: unknown}).executeCommand = + originalExecuteCommand; + }); + + it("shows a warning with a Login action and does not reveal the log", async () => { + let logShown = 0; + const deps = makePythonSetupDeps( + makeWiring({ + log: { + append: () => {}, + show: () => { + logShown++; + }, + }, + }) + ); + + await deps.showReauthPrompt(); + + expect(shownWith).to.have.length(1); + expect(shownWith[0].actions).to.deep.equal(["Login"]); + // An expired session is expected, not a defect: no log channel reveal. + expect(logShown).to.equal(0); + }); + + it("runs the re-auth command when Login is picked", async () => { + const deps = makePythonSetupDeps(makeWiring()); + reply = "Login"; + + await deps.showReauthPrompt(); + + expect(executed).to.deep.equal([ + "databricks.connection.configureLogin", + ]); + }); + + it("runs nothing when the prompt is dismissed", async () => { + const deps = makePythonSetupDeps(makeWiring()); + reply = undefined; + + await deps.showReauthPrompt(); + + expect(executed).to.have.length(0); + }); +}); diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index 09f13707f..a33de7e82 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -182,6 +182,17 @@ export interface PythonSetupWiringDeps { telemetry: Telemetry; } +/** User-facing copy for the expired-session re-login prompt. */ +const REAUTH_PROMPT_MESSAGE = + "Your Databricks session has expired. Log in again to set up the Python environment."; + +/** + * The extension's re-auth command (opens the login flow for the active profile + * and reconnects). Reused rather than shelling out to `databricks auth login` + * so host, profile, and workspace-id routing stay owned by one place. + */ +const RELOGIN_COMMAND_ID = "databricks.connection.configureLogin"; + /** * Assemble the {@link PythonSetupSetupDeps} for the real extension: the gate and * compute resolution come from the pure helpers above, the interpreter is @@ -247,6 +258,20 @@ export function makePythonSetupDeps( // revealing the (empty) output channel. await window.showWarningMessage(message); }, + showReauthPrompt: async () => { + // A warning, not an error, and no log reveal: an expired session is + // expected, not a defect. The "Login" button runs the extension's + // existing re-auth flow for the active profile; the user re-runs + // setup once connected (we deliberately don't auto-retry). + const login = "Login"; + const picked = await window.showWarningMessage( + REAUTH_PROMPT_MESSAGE, + login + ); + if (picked === login) { + await commands.executeCommand(RELOGIN_COMMAND_ID); + } + }, showError: async ( message: string, detail?: string, diff --git a/packages/databricks-vscode/src/python-setup/gateways/PythonSetupCliClient.test.ts b/packages/databricks-vscode/src/python-setup/gateways/PythonSetupCliClient.test.ts index 56e5a2cb1..ddaf8e477 100644 --- a/packages/databricks-vscode/src/python-setup/gateways/PythonSetupCliClient.test.ts +++ b/packages/databricks-vscode/src/python-setup/gateways/PythonSetupCliClient.test.ts @@ -9,6 +9,7 @@ import { SUCCESS_DEFAULT, ERROR_NO_TARGET, } from "../models/fixtures/setupLocalResults"; +import {isReauthRequiredError} from "../utils/authErrors"; /** * A fake child process that emits scripted stdout/stderr then closes. Lets us @@ -242,6 +243,30 @@ describe("PythonSetupCliClient", () => { expect(threw).to.equal(true); }); + it("produces a rejection the reauth matcher recognizes (gateway stderr → reauth gate)", async () => { + // The real shape of an expired-session abort: no JSON on stdout, the + // CLI's reauth guidance on stderr. This links the gateway's + // stderr-appending format to `isReauthRequiredError` (the controller's + // positive gate), so a change to either side that would silently break + // the re-login prompt fails here instead of in production. + const authStderr = + "Error: a new access token could not be retrieved because the " + + "refresh token is invalid. To reauthenticate, run the following " + + "command:\n databricks auth login --profile dev\n"; + const client = new PythonSetupCliClient( + () => "/fake/databricks", + () => ({}), + fakeSpawn({stdout: "", stderr: authStderr, code: 1}) + ); + let message = ""; + try { + await client.run(inv, {cwd: "/proj"}); + } catch (e) { + message = (e as Error).message; + } + expect(isReauthRequiredError(message)).to.equal(true); + }); + it("decodes a multi-byte char split across two stdout chunks", async () => { // "…" (U+2026) is 3 bytes in UTF-8; split it down the middle so each // chunk holds a partial code point. Naive per-chunk toString() would diff --git a/packages/databricks-vscode/src/python-setup/utils/authErrors.test.ts b/packages/databricks-vscode/src/python-setup/utils/authErrors.test.ts new file mode 100644 index 000000000..6941c1d77 --- /dev/null +++ b/packages/databricks-vscode/src/python-setup/utils/authErrors.test.ts @@ -0,0 +1,91 @@ +import {expect} from "chai"; +import {isReauthRequiredError} from "./authErrors"; + +/** + * The verbatim CLI abort the extension sees when the active profile's session + * has expired: a parse error (no JSON on stdout) with the CLI's auth diagnostic + * appended from stderr by the setup-local gateway. + */ +const EXPIRED_SESSION_MSG = + "CLI did not return valid JSON: Unexpected end of JSON input\n" + + "Error: A new access token could not be retrieved because the refresh " + + "token is invalid. To reauthenticate, run the following command: " + + "$ databricks auth login --profile dev"; + +describe("isReauthRequiredError", () => { + it("matches the expired-session CLI abort", () => { + expect(isReauthRequiredError(EXPIRED_SESSION_MSG)).to.be.true; + }); + + it("matches the hyphenated re-authenticate spelling", () => { + expect( + isReauthRequiredError( + "Error: token expired. To re-authenticate, run: databricks auth login --profile dev" + ) + ).to.be.true; + }); + + it("matches when the cause is split across lines (whitespace collapsed)", () => { + expect( + isReauthRequiredError( + "Error: the refresh token\nis invalid.\nRun: databricks auth login" + ) + ).to.be.true; + }); + + it("matches the access-token-could-not-be-retrieved cause", () => { + expect( + isReauthRequiredError( + "a new access token could not be retrieved. run databricks auth login to continue" + ) + ).to.be.true; + }); + + it("is case-insensitive", () => { + expect( + isReauthRequiredError( + "THE REFRESH TOKEN IS INVALID. RUN DATABRICKS AUTH LOGIN" + ) + ).to.be.true; + }); + + // Precision: a false positive would mislabel a real defect as expiry and + // suppress its report, so these must NOT match. + it("does not match a generic parse error with no auth signature", () => { + expect( + isReauthRequiredError( + "CLI did not return valid JSON: Unexpected end of JSON input" + ) + ).to.be.false; + }); + + it("does not match a network failure that merely mentions a login host", () => { + expect( + isReauthRequiredError( + "error: failed to reach https://login.example.com: connection refused" + ) + ).to.be.false; + }); + + it("does not match a package-index fetch failure", () => { + expect( + isReauthRequiredError( + "error: Failed to fetch: `https://pypi.org/simple/ipykernel/`\n" + + " Caused by: tcp connect error: Connection refused (os error 61)" + ) + ).to.be.false; + }); + + it("does not match a reauth cause without the CLI login remediation", () => { + // The remediation command is the precise half of the signal; a cause + // phrase alone (no `databricks auth login`) stays on the report path. + expect(isReauthRequiredError("the refresh token is invalid")).to.be + .false; + }); + + it("is safe on empty and non-string input", () => { + expect(isReauthRequiredError("")).to.be.false; + expect(isReauthRequiredError(undefined as unknown as string)).to.be + .false; + }); +}); diff --git a/packages/databricks-vscode/src/python-setup/utils/authErrors.ts b/packages/databricks-vscode/src/python-setup/utils/authErrors.ts new file mode 100644 index 000000000..f219e2601 --- /dev/null +++ b/packages/databricks-vscode/src/python-setup/utils/authErrors.ts @@ -0,0 +1,37 @@ +/** + * Recognizing the one setup-local failure that is an expected, self-service + * condition rather than a defect: the active profile's session can no longer be + * refreshed, so the CLI aborts before producing a result and the user must + * simply log in again. + */ + +/** + * Whether a setup-local failure message is the CLI's "your session expired, + * re-authenticate" abort — as opposed to a genuine spawn/parse/CLI defect or a + * transient network problem. + * + * Used as a *positive gate*: a match routes to a re-login prompt, and anything + * else (including a drifted wording this misses) falls through to the normal + * report path — so a false negative is never worse than the pre-existing + * behavior, while a false positive would mislabel a real defect and suppress + * its report. It is therefore tuned for precision: it requires the CLI's own + * `databricks auth login` remediation *and* a matching cause, so an unrelated + * failure that merely mentions a login URL cannot trip it. Whitespace is + * collapsed first so a cause split across lines still matches, and the + * hyphenated spelling is accepted. The message reaches us as the rejected + * error's `.message` (a parse error with the CLI's stderr appended by the + * setup-local gateway). + */ +export function isReauthRequiredError(message: string): boolean { + if (typeof message !== "string" || message.length === 0) { + return false; + } + const text = message.toLowerCase().replace(/\s+/g, " "); + const mentionsLoginRemediation = text.includes("databricks auth login"); + const mentionsReauthCause = + text.includes("reauthenticate") || + text.includes("re-authenticate") || + text.includes("refresh token is invalid") || + text.includes("access token could not be retrieved"); + return mentionsLoginRemediation && mentionsReauthCause; +}