From 2b6286f23b84015af10de6d191f051d59235a052 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 23 Sep 2025 12:24:28 +0300 Subject: [PATCH 01/10] Improve consistency in client-side login/logout experience --- src/commands.ts | 57 ++++++++++++++++++++++---------------- src/core/secretsManager.ts | 21 +++++++++++--- src/extension.ts | 23 ++++++++++----- src/remote/remote.ts | 14 ++-------- 4 files changed, 68 insertions(+), 47 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index bd4071cc..c7420623 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -179,19 +179,17 @@ export class Commands { } /** - * Log into the provided deployment. If the deployment URL is not specified, + * Log into the provided deployment. If the deployment URL is not specified, * ask for it first with a menu showing recent URLs along with the default URL * and CODER_URL, if those are set. */ - public async login(...args: string[]): Promise { - // Destructure would be nice but VS Code can pass undefined which errors. - const inputUrl = args[0]; - const inputToken = args[1]; - const inputLabel = args[2]; - const isAutologin = - typeof args[3] === "undefined" ? false : Boolean(args[3]); - - const url = await this.maybeAskUrl(inputUrl); + public async login(args?: { + url?: string; + token?: string; + label?: string; + autoLogin?: boolean; + }): Promise { + const url = await this.maybeAskUrl(args?.url); if (!url) { return; // The user aborted. } @@ -199,11 +197,14 @@ export class Commands { // It is possible that we are trying to log into an old-style host, in which // case we want to write with the provided blank label instead of generating // a host label. - const label = - typeof inputLabel === "undefined" ? toSafeHost(url) : inputLabel; + const label = args?.label === undefined ? toSafeHost(url) : args?.label; // Try to get a token from the user, if we need one, and their user. - const res = await this.maybeAskToken(url, inputToken, isAutologin); + const res = await this.maybeAskToken( + url, + args?.token, + args?.autoLogin === true, + ); if (!res) { return; // The user aborted, or unable to auth. } @@ -257,19 +258,21 @@ export class Commands { */ private async maybeAskToken( url: string, - token: string, - isAutologin: boolean, + token: string | undefined, + isAutoLogin: boolean, ): Promise<{ user: User; token: string } | null> { const client = CoderApi.create(url, token, this.logger); - if (!needToken(vscode.workspace.getConfiguration())) { - try { - const user = await client.getAuthenticatedUser(); - // For non-token auth, we write a blank token since the `vscodessh` - // command currently always requires a token file. - return { token: "", user }; - } catch (err) { + const needsToken = needToken(vscode.workspace.getConfiguration()); + try { + const user = await client.getAuthenticatedUser(); + // For non-token auth, we write a blank token since the `vscodessh` + // command currently always requires a token file. + // For token auth, we have valid access so we can just return the user here + return { token: needsToken && token ? token : "", user }; + } catch (err) { + if (!needToken(vscode.workspace.getConfiguration())) { const message = getErrorMessage(err, "no response from the server"); - if (isAutologin) { + if (isAutoLogin) { this.logger.warn("Failed to log in to Coder server:", message); } else { this.vscodeProposed.window.showErrorMessage( @@ -301,6 +304,9 @@ export class Commands { value: token || (await this.secretsManager.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { + if (!value) { + return null; + } client.setSessionToken(value); try { user = await client.getAuthenticatedUser(); @@ -369,7 +375,10 @@ export class Commands { // Sanity check; command should not be available if no url. throw new Error("You are not logged in"); } + await this.forceLogout(); + } + public async forceLogout(): Promise { // Clear from the REST client. An empty url will indicate to other parts of // the code that we are logged out. this.restClient.setHost(""); @@ -388,7 +397,7 @@ export class Commands { .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { if (action === "Login") { - vscode.commands.executeCommand("coder.login"); + this.login(); } }); diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 6a6666da..b3f141d7 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,4 +1,6 @@ -import type { SecretStorage } from "vscode"; +import type { SecretStorage, Disposable } from "vscode"; + +const SESSION_TOKEN_KEY = "sessionToken"; export class SecretsManager { constructor(private readonly secrets: SecretStorage) {} @@ -8,9 +10,9 @@ export class SecretsManager { */ public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { - await this.secrets.delete("sessionToken"); + await this.secrets.delete(SESSION_TOKEN_KEY); } else { - await this.secrets.store("sessionToken", sessionToken); + await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); } } @@ -19,11 +21,22 @@ export class SecretsManager { */ public async getSessionToken(): Promise { try { - return await this.secrets.get("sessionToken"); + return await this.secrets.get(SESSION_TOKEN_KEY); } catch { // The VS Code session store has become corrupt before, and // will fail to get the session token... return undefined; } } + + /** + * Subscribe to changes to the session token which can be used to indicate user login status. + */ + public onDidChangeSessionToken(listener: () => Promise): Disposable { + return this.secrets.onDidChange((e) => { + if (e.key === SESSION_TOKEN_KEY) { + listener(); + } + }); + } } diff --git a/src/extension.ts b/src/extension.ts index e069c3a3..01e60b9a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -327,6 +327,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), ); + ctx.subscriptions.push( + secretsManager.onDidChangeSessionToken(async () => { + const token = await secretsManager.getSessionToken(); + const url = mementoManager.getUrl(); + if (!token) { + output.info("Logging out"); + await commands.forceLogout(); + } else if (url) { + output.info("Logging in"); + // Should login the user directly if the URL+Token are valid + await commands.login({ url, token }); + } + }), + ); + // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. @@ -439,13 +454,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { cfg.get("coder.defaultUrl")?.trim() || process.env.CODER_URL?.trim(); if (defaultUrl) { - vscode.commands.executeCommand( - "coder.login", - defaultUrl, - undefined, - undefined, - "true", - ); + commands.login({ url: defaultUrl, autoLogin: true }); } } } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 2a286ab4..3a7af6fb 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -257,12 +257,7 @@ export class Remote { await this.closeRemote(); } else { // Log in then try again. - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); + await this.commands.login({ url: baseUrlRaw, label: parts.label }); await this.setup(remoteAuthority, firstConnect); } return; @@ -377,12 +372,7 @@ export class Remote { if (!result) { await this.closeRemote(); } else { - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); + await this.commands.login({ url: baseUrlRaw, label: parts.label }); await this.setup(remoteAuthority, firstConnect); } return; From f93317790946e8dc2e407e586a5a6679fe357e20 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 23 Sep 2025 16:55:45 +0300 Subject: [PATCH 02/10] Only try to authenticate when possible --- src/commands.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index c7420623..56ec31ea 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -263,14 +263,14 @@ export class Commands { ): Promise<{ user: User; token: string } | null> { const client = CoderApi.create(url, token, this.logger); const needsToken = needToken(vscode.workspace.getConfiguration()); - try { - const user = await client.getAuthenticatedUser(); - // For non-token auth, we write a blank token since the `vscodessh` - // command currently always requires a token file. - // For token auth, we have valid access so we can just return the user here - return { token: needsToken && token ? token : "", user }; - } catch (err) { - if (!needToken(vscode.workspace.getConfiguration())) { + if (!needsToken || token) { + try { + const user = await client.getAuthenticatedUser(); + // For non-token auth, we write a blank token since the `vscodessh` + // command currently always requires a token file. + // For token auth, we have valid access so we can just return the user here + return { token: needsToken && token ? token : "", user }; + } catch (err) { const message = getErrorMessage(err, "no response from the server"); if (isAutoLogin) { this.logger.warn("Failed to log in to Coder server:", message); From df63df6272958ca20d8e12ea7cdebfdbcce28b83 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 25 Sep 2025 17:52:14 +0300 Subject: [PATCH 03/10] Add a race between login event and dialog promise when connecting and logged out --- src/extension.ts | 5 ++- src/remote/remote.ts | 94 +++++++++++++++++++++++++++++--------------- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 01e60b9a..e6b7265c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -327,6 +327,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), ); + const remote = new Remote(serviceContainer, commands, ctx.extensionMode); + ctx.subscriptions.push( secretsManager.onDidChangeSessionToken(async () => { const token = await secretsManager.getSessionToken(); @@ -338,6 +340,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { output.info("Logging in"); // Should login the user directly if the URL+Token are valid await commands.login({ url, token }); + // Resolve any pending login detection promises + remote.resolveLoginDetected(); } }), ); @@ -352,7 +356,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (this would require the user to uninstall the Coder extension and // reinstall after installing the remote SSH extension, which is annoying) if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { - const remote = new Remote(serviceContainer, commands, ctx.extensionMode); try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 3a7af6fb..c954216c 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -59,6 +59,9 @@ export class Remote { private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; + private loginDetectedResolver: (() => void) | undefined; + private loginDetectedPromise: Promise = Promise.resolve(); + public constructor( serviceContainer: ServiceContainer, private readonly commands: Commands, @@ -70,6 +73,27 @@ export class Remote { this.cliManager = serviceContainer.getCliManager(); } + /** + * Creates a new promise that will be resolved when login is detected in another window. + * This should be called when starting a setup operation that might need login. + */ + private createLoginDetectionPromise(): void { + this.loginDetectedPromise = new Promise((resolve) => { + this.loginDetectedResolver = resolve; + }); + } + + /** + * Resolves the current login detection promise if one exists. + * This should be called from the extension when login is detected. + */ + public resolveLoginDetected(): void { + if (this.loginDetectedResolver) { + this.loginDetectedResolver(); + this.loginDetectedResolver = undefined; + } + } + private async confirmStart(workspaceName: string): Promise { const action = await this.vscodeProposed.window.showInformationMessage( `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, @@ -233,34 +257,54 @@ export class Remote { // Migrate "session_token" file to "session", if needed. await this.migrateSessionToken(parts.label); + // Try to detect any login event that might happen after we read the current configs + this.createLoginDetectionPromise(); // Get the URL and token belonging to this host. const { url: baseUrlRaw, token } = await this.cliManager.readConfig( parts.label, ); - // It could be that the cli config was deleted. If so, ask for the url. - if ( - !baseUrlRaw || - (!token && needToken(vscode.workspace.getConfiguration())) - ) { - const result = await this.vscodeProposed.window.showInformationMessage( - "You are not logged in...", + const showLoginDialog = async (message: string) => { + const dialogPromise = this.vscodeProposed.window.showInformationMessage( + message, { useCustom: true, modal: true, - detail: `You must log in to access ${workspaceName}.`, + detail: `You must log in to access ${workspaceName}. If you've already logged in, you may close this dialog.`, }, "Log In", ); - if (!result) { - // User declined to log in. - await this.closeRemote(); + + // Race between dialog and login detection + const result = await Promise.race([ + this.loginDetectedPromise.then(() => ({ type: "login" as const })), + dialogPromise.then((userChoice) => ({ + type: "dialog" as const, + userChoice, + })), + ]); + + if (result.type === "login") { + return this.setup(remoteAuthority, firstConnect); } else { - // Log in then try again. - await this.commands.login({ url: baseUrlRaw, label: parts.label }); - await this.setup(remoteAuthority, firstConnect); + if (!result.userChoice) { + // User declined to log in. + await this.closeRemote(); + return; + } else { + // Log in then try again. + await this.commands.login({ url: baseUrlRaw, label: parts.label }); + return this.setup(remoteAuthority, firstConnect); + } } - return; + }; + + // It could be that the cli config was deleted. If so, ask for the url. + if ( + !baseUrlRaw || + (!token && needToken(vscode.workspace.getConfiguration())) + ) { + return showLoginDialog("You are not logged in..."); } this.logger.info("Using deployment URL", baseUrlRaw); @@ -326,6 +370,8 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { + // We could've logged out in the meantime + this.createLoginDetectionPromise(); this.logger.info(`Looking for workspace ${workspaceName}...`); workspace = await workspaceClient.getWorkspaceByOwnerAndName( parts.username, @@ -359,23 +405,7 @@ export class Remote { return; } case 401: { - const result = - await this.vscodeProposed.window.showInformationMessage( - "Your session expired...", - { - useCustom: true, - modal: true, - detail: `You must log in to access ${workspaceName}.`, - }, - "Log In", - ); - if (!result) { - await this.closeRemote(); - } else { - await this.commands.login({ url: baseUrlRaw, label: parts.label }); - await this.setup(remoteAuthority, firstConnect); - } - return; + return showLoginDialog("Your session expired..."); } default: throw error; From edb505cfc5cd279b4539706cb74ac8f6d1ae6c27 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 28 Sep 2025 16:48:06 +0300 Subject: [PATCH 04/10] Clean up --- src/commands.ts | 9 +++------ src/remote/remote.ts | 8 ++------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 56ec31ea..d7df495b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -197,14 +197,11 @@ export class Commands { // It is possible that we are trying to log into an old-style host, in which // case we want to write with the provided blank label instead of generating // a host label. - const label = args?.label === undefined ? toSafeHost(url) : args?.label; + const label = args?.label === undefined ? toSafeHost(url) : args.label; // Try to get a token from the user, if we need one, and their user. - const res = await this.maybeAskToken( - url, - args?.token, - args?.autoLogin === true, - ); + const autoLogin = args?.autoLogin === true; + const res = await this.maybeAskToken(url, args?.token, autoLogin); if (!res) { return; // The user aborted, or unable to auth. } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index c954216c..1e0eb11f 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -59,6 +59,7 @@ export class Remote { private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; + // Used to race between the login dialog and the logging in from a different window private loginDetectedResolver: (() => void) | undefined; private loginDetectedPromise: Promise = Promise.resolve(); @@ -75,7 +76,6 @@ export class Remote { /** * Creates a new promise that will be resolved when login is detected in another window. - * This should be called when starting a setup operation that might need login. */ private createLoginDetectionPromise(): void { this.loginDetectedPromise = new Promise((resolve) => { @@ -85,7 +85,6 @@ export class Remote { /** * Resolves the current login detection promise if one exists. - * This should be called from the extension when login is detected. */ public resolveLoginDetected(): void { if (this.loginDetectedResolver) { @@ -257,14 +256,13 @@ export class Remote { // Migrate "session_token" file to "session", if needed. await this.migrateSessionToken(parts.label); - // Try to detect any login event that might happen after we read the current configs - this.createLoginDetectionPromise(); // Get the URL and token belonging to this host. const { url: baseUrlRaw, token } = await this.cliManager.readConfig( parts.label, ); const showLoginDialog = async (message: string) => { + this.createLoginDetectionPromise(); const dialogPromise = this.vscodeProposed.window.showInformationMessage( message, { @@ -370,8 +368,6 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { - // We could've logged out in the meantime - this.createLoginDetectionPromise(); this.logger.info(`Looking for workspace ${workspaceName}...`); workspace = await workspaceClient.getWorkspaceByOwnerAndName( parts.username, From 21523b115f70375fa96cf5f1f15667f1be965371 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 30 Sep 2025 15:51:05 +0300 Subject: [PATCH 05/10] Guard against multiple login promises at the same time --- src/remote/remote.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 1e0eb11f..4218d995 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -61,6 +61,7 @@ export class Remote { // Used to race between the login dialog and the logging in from a different window private loginDetectedResolver: (() => void) | undefined; + private loginDetectedRejector: ((reason?: Error) => void) | undefined; private loginDetectedPromise: Promise = Promise.resolve(); public constructor( @@ -78,8 +79,14 @@ export class Remote { * Creates a new promise that will be resolved when login is detected in another window. */ private createLoginDetectionPromise(): void { - this.loginDetectedPromise = new Promise((resolve) => { + if (this.loginDetectedRejector) { + this.loginDetectedRejector( + new Error("Login detection cancelled - new login attempt started"), + ); + } + this.loginDetectedPromise = new Promise((resolve, reject) => { this.loginDetectedResolver = resolve; + this.loginDetectedRejector = reject; }); } @@ -90,6 +97,7 @@ export class Remote { if (this.loginDetectedResolver) { this.loginDetectedResolver(); this.loginDetectedResolver = undefined; + this.loginDetectedRejector = undefined; } } From 27569d28cc288e3eff9e47919b398bf4b4262271 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 30 Sep 2025 16:41:15 +0300 Subject: [PATCH 06/10] Attempt to fix race condition between cliManager and secretsManager setting the session token --- src/commands.ts | 6 +++--- src/extension.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index d7df495b..1bc76db1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -211,13 +211,13 @@ export class Commands { this.restClient.setHost(url); this.restClient.setSessionToken(res.token); + // Store on disk to be used by the cli. + await this.cliManager.configure(label, url, res.token); + // Store these to be used in later sessions. await this.mementoManager.setUrl(url); await this.secretsManager.setSessionToken(res.token); - // Store on disk to be used by the cli. - await this.cliManager.configure(label, url, res.token); - // These contexts control various menu items and the sidebar. await vscode.commands.executeCommand( "setContext", diff --git a/src/extension.ts b/src/extension.ts index e6b7265c..92e83d35 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -167,14 +167,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const token = needToken(vscode.workspace.getConfiguration()) ? params.get("token") : (params.get("token") ?? ""); + + // Store on disk to be used by the cli. + await cliManager.configure(toSafeHost(url), url, token); + if (token) { client.setSessionToken(token); await secretsManager.setSessionToken(token); } - // Store on disk to be used by the cli. - await cliManager.configure(toSafeHost(url), url, token); - vscode.commands.executeCommand( "coder.open", owner, From fe96eb54209277dee5c5da5fddc6849e4ad683d2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 1 Oct 2025 17:56:51 +0300 Subject: [PATCH 07/10] Introduce ContextManager to hold context state globally + Rely on secrets to propagate authentication events between windows --- src/commands.ts | 28 ++++++++++++++--------- src/core/container.ts | 8 +++++++ src/core/contextManager.ts | 33 +++++++++++++++++++++++++++ src/core/secretsManager.ts | 25 +++++++++++++------- src/extension.ts | 38 ++++++++++++++----------------- src/remote/remote.ts | 4 ++++ src/workspace/workspaceMonitor.ts | 8 +++---- 7 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 src/core/contextManager.ts diff --git a/src/commands.ts b/src/commands.ts index 1bc76db1..68a1d289 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,6 +12,7 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { type CliManager } from "./core/cliManager"; import { type ServiceContainer } from "./core/container"; +import { type ContextManager } from "./core/contextManager"; import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; @@ -32,6 +33,7 @@ export class Commands { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not // possible to pass in arguments, so we have to store the current workspace @@ -53,6 +55,7 @@ export class Commands { this.mementoManager = serviceContainer.getMementoManager(); this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); + this.contextManager = serviceContainer.getContextManager(); } /** @@ -189,6 +192,11 @@ export class Commands { label?: string; autoLogin?: boolean; }): Promise { + if (this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging in"); + const url = await this.maybeAskUrl(args?.url); if (!url) { return; // The user aborted. @@ -219,13 +227,9 @@ export class Commands { await this.secretsManager.setSessionToken(res.token); // These contexts control various menu items and the sidebar. - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + this.contextManager.set("coder.authenticated", true); if (res.user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand("setContext", "coder.isOwner", true); + this.contextManager.set("coder.isOwner", true); } vscode.window @@ -245,6 +249,7 @@ export class Commands { // Fetch workspaces for the new deployment. vscode.commands.executeCommand("coder.refreshWorkspaces"); + this.secretsManager.triggerLoginStateChange("login"); } /** @@ -376,6 +381,10 @@ export class Commands { } public async forceLogout(): Promise { + if (!this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging out"); // Clear from the REST client. An empty url will indicate to other parts of // the code that we are logged out. this.restClient.setHost(""); @@ -385,11 +394,7 @@ export class Commands { await this.mementoManager.setUrl(undefined); await this.secretsManager.setSessionToken(undefined); - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - false, - ); + this.contextManager.set("coder.authenticated", false); vscode.window .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { @@ -400,6 +405,7 @@ export class Commands { // This will result in clearing the workspace list. vscode.commands.executeCommand("coder.refreshWorkspaces"); + this.secretsManager.triggerLoginStateChange("logout"); } /** diff --git a/src/core/container.ts b/src/core/container.ts index 72f28088..a8f938ea 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; import { CliManager } from "./cliManager"; +import { ContextManager } from "./contextManager"; import { MementoManager } from "./mementoManager"; import { PathResolver } from "./pathResolver"; import { SecretsManager } from "./secretsManager"; @@ -17,6 +18,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; constructor( context: vscode.ExtensionContext, @@ -34,6 +36,7 @@ export class ServiceContainer implements vscode.Disposable { this.logger, this.pathResolver, ); + this.contextManager = new ContextManager(); } getVsCodeProposed(): typeof vscode { @@ -60,10 +63,15 @@ export class ServiceContainer implements vscode.Disposable { return this.cliManager; } + getContextManager(): ContextManager { + return this.contextManager; + } + /** * Dispose of all services and clean up resources. */ dispose(): void { + this.contextManager.dispose(); this.logger.dispose(); } } diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts new file mode 100644 index 00000000..a5a18397 --- /dev/null +++ b/src/core/contextManager.ts @@ -0,0 +1,33 @@ +import * as vscode from "vscode"; + +const CONTEXT_DEFAULTS = { + "coder.authenticated": false, + "coder.isOwner": false, + "coder.loaded": false, + "coder.workspace.updatable": false, +} as const; + +type CoderContext = keyof typeof CONTEXT_DEFAULTS; + +export class ContextManager implements vscode.Disposable { + private readonly context = new Map(); + + public constructor() { + (Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => { + this.set(key, CONTEXT_DEFAULTS[key]); + }); + } + + public set(key: CoderContext, value: boolean): void { + this.context.set(key, value); + vscode.commands.executeCommand("setContext", key, value); + } + + public get(key: CoderContext): boolean { + return this.context.get(key) ?? CONTEXT_DEFAULTS[key]; + } + + public dispose() { + this.context.clear(); + } +} diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index b3f141d7..06991c51 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -2,8 +2,13 @@ import type { SecretStorage, Disposable } from "vscode"; const SESSION_TOKEN_KEY = "sessionToken"; +const LOGIN_STATE_KEY = "loginState"; + +type AuthAction = "login" | "logout"; export class SecretsManager { - constructor(private readonly secrets: SecretStorage) {} + constructor(private readonly secrets: SecretStorage) { + void this.secrets.delete(LOGIN_STATE_KEY); + } /** * Set or unset the last used token. @@ -29,13 +34,17 @@ export class SecretsManager { } } - /** - * Subscribe to changes to the session token which can be used to indicate user login status. - */ - public onDidChangeSessionToken(listener: () => Promise): Disposable { - return this.secrets.onDidChange((e) => { - if (e.key === SESSION_TOKEN_KEY) { - listener(); + public triggerLoginStateChange(action: AuthAction): void { + this.secrets.store(LOGIN_STATE_KEY, action); + } + + public onDidChangeLoginState( + listener: (state?: AuthAction) => Promise, + ): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key === LOGIN_STATE_KEY) { + const state = await this.secrets.get(LOGIN_STATE_KEY); + listener(state as AuthAction | undefined); } }); } diff --git a/src/extension.ts b/src/extension.ts index 92e83d35..098a77dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -62,6 +62,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const output = serviceContainer.getLogger(); const mementoManager = serviceContainer.getMementoManager(); const secretsManager = serviceContainer.getSecretsManager(); + const contextManager = serviceContainer.getContextManager(); // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); @@ -331,18 +332,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const remote = new Remote(serviceContainer, commands, ctx.extensionMode); ctx.subscriptions.push( - secretsManager.onDidChangeSessionToken(async () => { - const token = await secretsManager.getSessionToken(); - const url = mementoManager.getUrl(); - if (!token) { - output.info("Logging out"); - await commands.forceLogout(); - } else if (url) { - output.info("Logging in"); + secretsManager.onDidChangeLoginState(async (state) => { + if (state === undefined) { + // Initalization - Ignore those events + return; + } + + if (state === "login") { + const token = await secretsManager.getSessionToken(); + const url = mementoManager.getUrl(); // Should login the user directly if the URL+Token are valid await commands.login({ url, token }); // Resolve any pending login detection promises remote.resolveLoginDetected(); + } else { + await commands.forceLogout(); } }), ); @@ -413,20 +417,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { output.info(`Logged in to ${baseUrl}; checking credentials`); client .getAuthenticatedUser() - .then(async (user) => { + .then((user) => { if (user && user.roles) { output.info("Credentials are valid"); - vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + contextManager.set("coder.authenticated", true); if (user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand( - "setContext", - "coder.isOwner", - true, - ); + contextManager.set("coder.isOwner", true); } // Fetch and monitor workspaces, now that we know the client is good. @@ -445,11 +441,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); }) .finally(() => { - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); }); } else { output.info("Not currently logged in"); - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); // Handle autologin, if not already logged in. const cfg = vscode.workspace.getConfiguration(); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 4218d995..c4984054 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -30,6 +30,7 @@ import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; import { type ServiceContainer } from "../core/container"; +import { type ContextManager } from "../core/contextManager"; import { type PathResolver } from "../core/pathResolver"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; @@ -58,6 +59,7 @@ export class Remote { private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; // Used to race between the login dialog and the logging in from a different window private loginDetectedResolver: (() => void) | undefined; @@ -73,6 +75,7 @@ export class Remote { this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.cliManager = serviceContainer.getCliManager(); + this.contextManager = serviceContainer.getContextManager(); } /** @@ -545,6 +548,7 @@ export class Remote { workspaceClient, this.logger, this.vscodeProposed, + this.contextManager, ); disposables.push(monitor); disposables.push( diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 8ff99137..0b154f75 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -7,6 +7,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; import { type CoderApi } from "../api/coderApi"; +import { type ContextManager } from "../core/contextManager"; import { type Logger } from "../logging/logger"; import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; @@ -41,6 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly logger: Logger, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, + private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); const socket = this.client.watchWorkspace(workspace); @@ -217,11 +219,7 @@ export class WorkspaceMonitor implements vscode.Disposable { } private updateContext(workspace: Workspace) { - vscode.commands.executeCommand( - "setContext", - "coder.workspace.updatable", - workspace.outdated, - ); + this.contextManager.set("coder.workspace.updatable", workspace.outdated); } private updateStatusBar(workspace: Workspace) { From 12c7c98fa6524b5a9a2071bcbd257ab09c5b04b4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 3 Oct 2025 15:30:05 +0300 Subject: [PATCH 08/10] Refactoring and added tests --- src/commands.ts | 10 +++--- src/core/secretsManager.ts | 28 ++++++++++++---- src/extension.ts | 7 ++-- src/remote/remote.ts | 2 +- test/mocks/testHelpers.ts | 30 ++++++++++++++--- test/unit/core/secretsManager.test.ts | 46 ++++++++++++++++++++++++--- 6 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 68a1d289..5abeb026 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -219,13 +219,13 @@ export class Commands { this.restClient.setHost(url); this.restClient.setSessionToken(res.token); - // Store on disk to be used by the cli. - await this.cliManager.configure(label, url, res.token); - // Store these to be used in later sessions. await this.mementoManager.setUrl(url); await this.secretsManager.setSessionToken(res.token); + // Store on disk to be used by the cli. + await this.cliManager.configure(label, url, res.token); + // These contexts control various menu items and the sidebar. this.contextManager.set("coder.authenticated", true); if (res.user.roles.find((role) => role.name === "owner")) { @@ -247,9 +247,9 @@ export class Commands { } }); + await this.secretsManager.triggerLoginStateChange("login"); // Fetch workspaces for the new deployment. vscode.commands.executeCommand("coder.refreshWorkspaces"); - this.secretsManager.triggerLoginStateChange("login"); } /** @@ -403,9 +403,9 @@ export class Commands { } }); + await this.secretsManager.triggerLoginStateChange("logout"); // This will result in clearing the workspace list. vscode.commands.executeCommand("coder.refreshWorkspaces"); - this.secretsManager.triggerLoginStateChange("logout"); } /** diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 06991c51..0161de24 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -5,10 +5,9 @@ const SESSION_TOKEN_KEY = "sessionToken"; const LOGIN_STATE_KEY = "loginState"; type AuthAction = "login" | "logout"; + export class SecretsManager { - constructor(private readonly secrets: SecretStorage) { - void this.secrets.delete(LOGIN_STATE_KEY); - } + constructor(private readonly secrets: SecretStorage) {} /** * Set or unset the last used token. @@ -34,17 +33,34 @@ export class SecretsManager { } } - public triggerLoginStateChange(action: AuthAction): void { - this.secrets.store(LOGIN_STATE_KEY, action); + /** + * Triggers a login/logout event that propagates across all VS Code windows. + * Uses the secrets storage onDidChange event as a cross-window communication mechanism. + * Appends a timestamp to ensure the value always changes, guaranteeing the event fires. + */ + public async triggerLoginStateChange(action: AuthAction): Promise { + const date = new Date().toISOString(); + await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`); } + /** + * Listens for login/logout events from any VS Code window. + * The secrets storage onDidChange event fires across all windows, enabling cross-window sync. + */ public onDidChangeLoginState( listener: (state?: AuthAction) => Promise, ): Disposable { return this.secrets.onDidChange(async (e) => { if (e.key === LOGIN_STATE_KEY) { const state = await this.secrets.get(LOGIN_STATE_KEY); - listener(state as AuthAction | undefined); + if (state?.startsWith("login")) { + listener("login"); + } else if (state?.startsWith("logout")) { + listener("logout"); + } else { + // Secret was deleted or is invalid + listener(undefined); + } } }); } diff --git a/src/extension.ts b/src/extension.ts index 098a77dd..9dc73d97 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -169,14 +169,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ? params.get("token") : (params.get("token") ?? ""); - // Store on disk to be used by the cli. - await cliManager.configure(toSafeHost(url), url, token); - if (token) { client.setSessionToken(token); await secretsManager.setSessionToken(token); } + // Store on disk to be used by the cli. + await cliManager.configure(toSafeHost(url), url, token); + vscode.commands.executeCommand( "coder.open", owner, @@ -334,7 +334,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions.push( secretsManager.onDidChangeLoginState(async (state) => { if (state === undefined) { - // Initalization - Ignore those events return; } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index c4984054..832a8086 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -61,7 +61,7 @@ export class Remote { private readonly cliManager: CliManager; private readonly contextManager: ContextManager; - // Used to race between the login dialog and the logging in from a different window + // Used to race between the login dialog and logging in from a different window private loginDetectedResolver: (() => void) | undefined; private loginDetectedRejector: ((reason?: Error) => void) | undefined; private loginDetectedPromise: Promise = Promise.resolve(); diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 14eca74b..5cfe44e5 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -234,10 +234,19 @@ export class InMemoryMemento implements vscode.Memento { export class InMemorySecretStorage implements vscode.SecretStorage { private secrets = new Map(); private isCorrupted = false; - - onDidChange: vscode.Event = () => ({ - dispose: () => {}, - }); + private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = []; + + onDidChange: vscode.Event = (listener) => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; async get(key: string): Promise { if (this.isCorrupted) { @@ -250,17 +259,30 @@ export class InMemorySecretStorage implements vscode.SecretStorage { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const oldValue = this.secrets.get(key); this.secrets.set(key, value); + if (oldValue !== value) { + this.fireChangeEvent(key); + } } async delete(key: string): Promise { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const hadKey = this.secrets.has(key); this.secrets.delete(key); + if (hadKey) { + this.fireChangeEvent(key); + } } corruptStorage(): void { this.isCorrupted = true; } + + private fireChangeEvent(key: string): void { + const event: vscode.SecretStorageChangeEvent = { key }; + this.listeners.forEach((listener) => listener(event)); + } } diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 7100a29b..8759d517 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { SecretsManager } from "@/core/secretsManager"; @@ -13,7 +13,7 @@ describe("SecretsManager", () => { secretsManager = new SecretsManager(secretStorage); }); - describe("setSessionToken", () => { + describe("session token", () => { it("should store and retrieve tokens", async () => { await secretsManager.setSessionToken("test-token"); expect(await secretsManager.getSessionToken()).toBe("test-token"); @@ -31,9 +31,7 @@ describe("SecretsManager", () => { await secretsManager.setSessionToken(undefined); expect(await secretsManager.getSessionToken()).toBeUndefined(); }); - }); - describe("getSessionToken", () => { it("should return undefined for corrupted storage", async () => { await secretStorage.store("sessionToken", "valid-token"); secretStorage.corruptStorage(); @@ -41,4 +39,44 @@ describe("SecretsManager", () => { expect(await secretsManager.getSessionToken()).toBeUndefined(); }); }); + + describe("login state", () => { + it("should trigger login events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + expect(events).toEqual(["login"]); + }); + + it("should trigger logout events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("logout"); + expect(events).toEqual(["logout"]); + }); + + it("should fire same event twice in a row", async () => { + vi.useFakeTimers(); + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + vi.advanceTimersByTime(5); + await secretsManager.triggerLoginStateChange("login"); + + expect(events).toEqual(["login", "login"]); + vi.useRealTimers(); + }); + }); }); From 2aa197cf70cedcc9af98157263eff814fb41136b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 6 Oct 2025 12:48:58 +0300 Subject: [PATCH 09/10] Make AuthAction an enum and enforce it's handling --- .eslintrc.json | 4 ++++ src/core/secretsManager.ts | 18 +++++++++++------ src/error.ts | 3 +++ src/extension.ts | 29 +++++++++++++++------------ test/unit/core/secretsManager.test.ts | 14 ++++++------- 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 91d67601..3e10c2bf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -46,6 +46,10 @@ "prefer": "type-imports", "fixStyle": "inline-type-imports" } + ], + "@typescript-eslint/switch-exhaustiveness-check": [ + "error", + { "considerDefaultExhaustiveForUnions": true } ] } }, diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 0161de24..94827b15 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -4,7 +4,11 @@ const SESSION_TOKEN_KEY = "sessionToken"; const LOGIN_STATE_KEY = "loginState"; -type AuthAction = "login" | "logout"; +export enum AuthAction { + LOGIN, + LOGOUT, + INVALID, +} export class SecretsManager { constructor(private readonly secrets: SecretStorage) {} @@ -38,7 +42,9 @@ export class SecretsManager { * Uses the secrets storage onDidChange event as a cross-window communication mechanism. * Appends a timestamp to ensure the value always changes, guaranteeing the event fires. */ - public async triggerLoginStateChange(action: AuthAction): Promise { + public async triggerLoginStateChange( + action: "login" | "logout", + ): Promise { const date = new Date().toISOString(); await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`); } @@ -48,18 +54,18 @@ export class SecretsManager { * The secrets storage onDidChange event fires across all windows, enabling cross-window sync. */ public onDidChangeLoginState( - listener: (state?: AuthAction) => Promise, + listener: (state: AuthAction) => Promise, ): Disposable { return this.secrets.onDidChange(async (e) => { if (e.key === LOGIN_STATE_KEY) { const state = await this.secrets.get(LOGIN_STATE_KEY); if (state?.startsWith("login")) { - listener("login"); + listener(AuthAction.LOGIN); } else if (state?.startsWith("logout")) { - listener("logout"); + listener(AuthAction.LOGOUT); } else { // Secret was deleted or is invalid - listener(undefined); + listener(AuthAction.INVALID); } } }); diff --git a/src/error.ts b/src/error.ts index 7b93b458..70448d76 100644 --- a/src/error.ts +++ b/src/error.ts @@ -64,6 +64,8 @@ export class CertificateError extends Error { return new CertificateError(err.message, X509_ERR.UNTRUSTED_LEAF); case X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN: return new CertificateError(err.message, X509_ERR.UNTRUSTED_CHAIN); + case undefined: + break; } } return err; @@ -154,6 +156,7 @@ export class CertificateError extends Error { ); switch (val) { case CertificateError.ActionOK: + case undefined: return; case CertificateError.ActionAllowInsecure: await this.allowInsecure(); diff --git a/src/extension.ts b/src/extension.ts index 9dc73d97..aba94cfe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; +import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; @@ -333,19 +334,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions.push( secretsManager.onDidChangeLoginState(async (state) => { - if (state === undefined) { - return; - } - - if (state === "login") { - const token = await secretsManager.getSessionToken(); - const url = mementoManager.getUrl(); - // Should login the user directly if the URL+Token are valid - await commands.login({ url, token }); - // Resolve any pending login detection promises - remote.resolveLoginDetected(); - } else { - await commands.forceLogout(); + switch (state) { + case AuthAction.LOGIN: { + const token = await secretsManager.getSessionToken(); + const url = mementoManager.getUrl(); + // Should login the user directly if the URL+Token are valid + await commands.login({ url, token }); + // Resolve any pending login detection promises + remote.resolveLoginDetected(); + break; + } + case AuthAction.LOGOUT: + await commands.forceLogout(); + break; + case AuthAction.INVALID: + break; } }), ); diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 8759d517..bfe8c713 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SecretsManager } from "@/core/secretsManager"; +import { AuthAction, SecretsManager } from "@/core/secretsManager"; import { InMemorySecretStorage } from "../../mocks/testHelpers"; @@ -42,30 +42,30 @@ describe("SecretsManager", () => { describe("login state", () => { it("should trigger login events", async () => { - const events: Array = []; + const events: Array = []; secretsManager.onDidChangeLoginState((state) => { events.push(state); return Promise.resolve(); }); await secretsManager.triggerLoginStateChange("login"); - expect(events).toEqual(["login"]); + expect(events).toEqual([AuthAction.LOGIN]); }); it("should trigger logout events", async () => { - const events: Array = []; + const events: Array = []; secretsManager.onDidChangeLoginState((state) => { events.push(state); return Promise.resolve(); }); await secretsManager.triggerLoginStateChange("logout"); - expect(events).toEqual(["logout"]); + expect(events).toEqual([AuthAction.LOGOUT]); }); it("should fire same event twice in a row", async () => { vi.useFakeTimers(); - const events: Array = []; + const events: Array = []; secretsManager.onDidChangeLoginState((state) => { events.push(state); return Promise.resolve(); @@ -75,7 +75,7 @@ describe("SecretsManager", () => { vi.advanceTimersByTime(5); await secretsManager.triggerLoginStateChange("login"); - expect(events).toEqual(["login", "login"]); + expect(events).toEqual([AuthAction.LOGIN, AuthAction.LOGIN]); vi.useRealTimers(); }); }); From 820cb1cb726c2fe3e72e9b96cbca0221a4ff80d2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 6 Oct 2025 12:55:54 +0300 Subject: [PATCH 10/10] Add rule to prevent setting context from outside the ContextManager class --- .eslintrc.json | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 3e10c2bf..32fb8e61 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,17 +23,6 @@ "import/internal-regex": "^@/" }, "overrides": [ - { - "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], - "settings": { - "import/resolver": { - "typescript": { - // In tests, resolve using the test tsconfig - "project": "test/tsconfig.json" - } - } - } - }, { "files": ["*.ts"], "rules": { @@ -53,6 +42,23 @@ ] } }, + { + "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], + "settings": { + "import/resolver": { + "typescript": { + // In tests, resolve using the test tsconfig + "project": "test/tsconfig.json" + } + } + } + }, + { + "files": ["src/core/contextManager.ts"], + "rules": { + "no-restricted-syntax": "off" + } + }, { "extends": ["plugin:package-json/legacy-recommended"], "files": ["*.json"], @@ -110,6 +116,13 @@ "sublings_only": true } } + ], + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.property.name='executeCommand'][arguments.0.value='setContext'][arguments.length>=3]", + "message": "Do not use executeCommand('setContext', ...) directly. Use the ContextManager class instead." + } ] } }