diff --git a/CHANGELOG.md b/CHANGELOG.md index ab29d574..b0aba83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ ## Unreleased +### Added + +- Opening a workspace that's already connected in another VS Code window now shows a prompt + to **Duplicate Window** (preserving tabs and panels) or **Open Without Folder**, instead of + just focusing the existing window with no way to open a second view of the same workspace. + ### Fixed - The **Coder: Workspace Build** output channel is no longer created when reconnecting to an diff --git a/src/commands.ts b/src/commands.ts index 3155b870..4cdedde1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,7 +1,3 @@ -import { - type Workspace, - type WorkspaceAgent, -} from "coder/site/src/api/typesGenerated"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -13,20 +9,11 @@ import { extractAgents, workspaceStatusLabel, } from "./api/api-helper"; -import { type CoderApi } from "./api/coderApi"; import * as cliExec from "./core/cliExec"; -import { type CliManager } from "./core/cliManager"; -import { type ServiceContainer } from "./core/container"; -import { type MementoManager } from "./core/mementoManager"; -import { type PathResolver } from "./core/pathResolver"; -import { type SecretsManager } from "./core/secretsManager"; import { appendVsCodeLogs } from "./core/supportBundleLogs"; -import { type DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; import { type FeatureSet, featureSetForVersion } from "./featureSet"; -import { type Logger } from "./logging/logger"; -import { type LoginCoordinator } from "./login/loginCoordinator"; import { withCancellableProgress, withProgress } from "./progress"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { @@ -42,6 +29,25 @@ import { WorkspaceTreeItem, } from "./workspace/workspacesProvider"; +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "./api/coderApi"; +import type { CliManager } from "./core/cliManager"; +import type { ServiceContainer } from "./core/container"; +import type { MementoManager } from "./core/mementoManager"; +import type { PathResolver } from "./core/pathResolver"; +import type { SecretsManager } from "./core/secretsManager"; +import type { DeploymentManager } from "./deployment/deploymentManager"; +import type { Logger } from "./logging/logger"; +import type { LoginCoordinator } from "./login/loginCoordinator"; +import type { + DuplicateWorkspaceIpc, + PongMessage, +} from "./workspace/duplicateWorkspaceIpc"; + interface OpenOptions { workspaceOwner?: string; workspaceName?: string; @@ -65,6 +71,7 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly loginCoordinator: LoginCoordinator; + private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc; // 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 @@ -88,6 +95,7 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); + this.duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc(); } /** @@ -1079,6 +1087,20 @@ export class Commands { // Only set the memento when opening a new folder/window await this.mementoManager.setStartupMode("start"); + + // Best-effort check for an already-connected window. Runs in the + // background so it never delays openFolder. + this.duplicateWorkspaceIpc + .sendPing(remoteAuthority) + .then((pong) => { + if (pong) { + this.showMultiWindowNotification(pong, remoteAuthority); + } + }) + .catch((err: unknown) => { + this.logger.error(`IPC ping failed for ${remoteAuthority}`, err); + }); + if (folderPath) { await vscode.commands.executeCommand( "vscode.openFolder", @@ -1100,6 +1122,38 @@ export class Commands { }); return true; } + + // VS Code may dismiss a non-modal info message without resolving the + // thenable, so this must not be awaited from the open path. + private showMultiWindowNotification( + pong: PongMessage, + remoteAuthority: string, + ): void { + const duplicateAction = "Duplicate Window"; + const openEmptyAction = "Open Without Folder"; + vscode.window + .showInformationMessage( + "This workspace is already open in another window.", + duplicateAction, + openEmptyAction, + ) + .then(async (choice) => { + if (choice === duplicateAction) { + await this.duplicateWorkspaceIpc.sendDuplicate(pong.sessionId); + } else if (choice === openEmptyAction) { + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority, + reuseWindow: false, + }); + } + }) + .then(undefined, (err: unknown) => { + this.logger.error( + `Multi-window notification failed for ${remoteAuthority}`, + err, + ); + }); + } } async function openFile(filePath: string): Promise { diff --git a/src/core/container.ts b/src/core/container.ts index ce8ca887..243fb346 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -1,8 +1,9 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; -import { type Logger } from "../logging/logger"; import { LoginCoordinator } from "../login/loginCoordinator"; +import { OAuthCallback } from "../oauth/oauthCallback"; +import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc"; import { CliCredentialManager } from "./cliCredentialManager"; import { CliManager } from "./cliManager"; @@ -11,6 +12,8 @@ import { MementoManager } from "./mementoManager"; import { PathResolver } from "./pathResolver"; import { SecretsManager } from "./secretsManager"; +import type { Logger } from "../logging/logger"; + /** * Service container for dependency injection. * Centralizes the creation and management of all core services. @@ -24,6 +27,8 @@ export class ServiceContainer implements vscode.Disposable { private readonly cliManager: CliManager; private readonly contextManager: ContextManager; private readonly loginCoordinator: LoginCoordinator; + private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc; + private readonly oauthCallback: OAuthCallback; constructor(context: vscode.ExtensionContext) { this.logger = vscode.window.createOutputChannel("Coder", { log: true }); @@ -63,13 +68,19 @@ export class ServiceContainer implements vscode.Disposable { this.cliCredentialManager, ); this.contextManager = new ContextManager(context); + this.oauthCallback = new OAuthCallback(context.secrets, this.logger); this.loginCoordinator = new LoginCoordinator( this.secretsManager, this.mementoManager, this.logger, this.cliCredentialManager, + this.oauthCallback, context.extension.id, ); + this.duplicateWorkspaceIpc = new DuplicateWorkspaceIpc( + context.secrets, + this.logger, + ); } getPathResolver(): PathResolver { @@ -104,6 +115,14 @@ export class ServiceContainer implements vscode.Disposable { return this.loginCoordinator; } + getDuplicateWorkspaceIpc(): DuplicateWorkspaceIpc { + return this.duplicateWorkspaceIpc; + } + + getOAuthCallback(): OAuthCallback { + return this.oauthCallback; + } + /** * Dispose of all services and clean up resources. */ diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 7d8d66cf..ac3ee6b1 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -16,7 +16,6 @@ const DEPLOYMENT_ACCESS_PREFIX = "coder.access."; type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX; -const OAUTH_CALLBACK_KEY = "coder.oauthCallback"; const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment"; const DEFAULT_MAX_DEPLOYMENTS = 10; @@ -51,14 +50,6 @@ const SessionAuthSchema = z.object({ export type SessionAuth = z.infer; -const OAuthCallbackDataSchema = z.object({ - state: z.string(), - code: z.string().nullable(), - error: z.string().nullable(), -}); - -type OAuthCallbackData = z.infer; - export class SecretsManager { constructor( private readonly secrets: SecretStorage, @@ -306,54 +297,6 @@ export class SecretsManager { return safeHostname; } - /** - * Write an OAuth callback result to secrets storage. - * Used for cross-window communication when OAuth callback arrives in a different window. - */ - public async setOAuthCallback(data: OAuthCallbackData): Promise { - const parsed = OAuthCallbackDataSchema.parse(data); - await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(parsed)); - } - - /** - * Listen for OAuth callback results from any VS Code window. - * The listener receives the state parameter, code (if success), and error (if failed). - */ - public onDidChangeOAuthCallback( - listener: (data: OAuthCallbackData) => void, - ): Disposable { - return this.secrets.onDidChange(async (e) => { - if (e.key !== OAUTH_CALLBACK_KEY) { - return; - } - - const raw = await this.secrets.get(OAUTH_CALLBACK_KEY); - if (!raw) { - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - this.logger.error("Failed to parse OAuth callback JSON", err); - return; - } - - const result = OAuthCallbackDataSchema.safeParse(parsed); - if (!result.success) { - this.logger.error("Invalid OAuth callback data shape", result.error); - return; - } - - try { - listener(result.data); - } catch (err) { - this.logger.error("Error in onDidChangeOAuthCallback listener", err); - } - }); - } - public getOAuthClientRegistration( safeHostname: string, ): Promise { diff --git a/src/extension.ts b/src/extension.ts index 3b54d1c0..cb38ecb3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -335,6 +335,30 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const remote = new Remote(serviceContainer, commands, ctx); + // Respond to PINGs and DUPLICATE commands from other windows. + const duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc(); + ctx.subscriptions.push( + duplicateWorkspaceIpc.onRequest(async (msg) => { + const currentAuthority = vscodeProposed.env.remoteAuthority; + if (!currentAuthority) { + return; + } + + if (msg.type === "ping" && msg.authority === currentAuthority) { + await duplicateWorkspaceIpc.sendPong(msg.id, vscode.env.sessionId); + } + + if ( + msg.type === "duplicate" && + msg.targetSessionId === vscode.env.sessionId + ) { + await vscode.commands.executeCommand( + "workbench.action.duplicateWorkspaceInNewWindow", + ); + } + }), + ); + // 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. diff --git a/src/ipc/windowBroadcast.ts b/src/ipc/windowBroadcast.ts new file mode 100644 index 00000000..436b184d --- /dev/null +++ b/src/ipc/windowBroadcast.ts @@ -0,0 +1,43 @@ +import type { Disposable, SecretStorage } from "vscode"; +import type { ZodType } from "zod"; + +import type { Logger } from "../logging/logger"; + +/** + * Typed pub/sub over a single SecretStorage key. SecretStorage.onDidChange + * fires across all VS Code windows, so each instance is a cross-window + * channel for messages of type T. + */ +export class WindowBroadcast { + constructor( + private readonly secrets: SecretStorage, + private readonly key: string, + private readonly schema: ZodType, + private readonly logger: Logger, + ) {} + + async send(msg: T): Promise { + await this.secrets.store(this.key, JSON.stringify(msg)); + } + + onReceive(handler: (msg: T) => void | Promise): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key !== this.key) { + return; + } + try { + const raw = await this.secrets.get(this.key); + if (!raw) { + return; + } + const parsed = this.schema.safeParse(JSON.parse(raw)); + if (!parsed.success) { + return; + } + await handler(parsed.data); + } catch (err) { + this.logger.error(`Error handling broadcast on ${this.key}`, err); + } + }); + } +} diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 0835330b..d894c383 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -19,6 +19,7 @@ import type { MementoManager } from "../core/mementoManager"; import type { OAuthTokenData, SecretsManager } from "../core/secretsManager"; import type { Deployment } from "../deployment/types"; import type { Logger } from "../logging/logger"; +import type { OAuthCallback } from "../oauth/oauthCallback"; type LoginResult = | { success: false } @@ -43,10 +44,12 @@ export class LoginCoordinator implements vscode.Disposable { private readonly mementoManager: MementoManager, private readonly logger: Logger, private readonly cliCredentialManager: CliCredentialManager, + oauthCallback: OAuthCallback, extensionId: string, ) { this.oauthAuthorizer = new OAuthAuthorizer( secretsManager, + oauthCallback, logger, extensionId, ); diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 98e2a119..7280e742 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,10 +1,6 @@ -import { type AxiosInstance } from "axios"; import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; -import { type SecretsManager } from "../core/secretsManager"; -import { type Deployment } from "../deployment/types"; -import { type Logger } from "../logging/logger"; import { AUTH_GRANT_TYPE, @@ -21,6 +17,7 @@ import { toUrlSearchParams, } from "./utils"; +import type { AxiosInstance } from "axios"; import type { OAuth2AuthorizationServerMetadata, OAuth2ClientRegistrationRequest, @@ -30,6 +27,12 @@ import type { User, } from "coder/site/src/api/typesGenerated"; +import type { SecretsManager } from "../core/secretsManager"; +import type { Deployment } from "../deployment/types"; +import type { Logger } from "../logging/logger"; + +import type { OAuthCallback } from "./oauthCallback"; + /** * Handles the OAuth authorization code flow for authenticating with Coder deployments. * Encapsulates client registration, PKCE challenge, and token exchange. @@ -39,6 +42,7 @@ export class OAuthAuthorizer implements vscode.Disposable { constructor( private readonly secretsManager: SecretsManager, + private readonly oauthCallback: OAuthCallback, private readonly logger: Logger, private readonly extensionId: string, ) {} @@ -247,7 +251,7 @@ export class OAuthAuthorizer implements vscode.Disposable { timeoutMins * 60 * 1000, ); - const listener = this.secretsManager.onDidChangeOAuthCallback( + const listener = this.oauthCallback.onReceive( ({ state: callbackState, code, error }) => { if (callbackState !== state) { this.logger.warn( diff --git a/src/oauth/oauthCallback.ts b/src/oauth/oauthCallback.ts new file mode 100644 index 00000000..14bc5166 --- /dev/null +++ b/src/oauth/oauthCallback.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { WindowBroadcast } from "../ipc/windowBroadcast"; + +import type { Disposable, SecretStorage } from "vscode"; + +import type { Logger } from "../logging/logger"; + +const OAUTH_CALLBACK_KEY = "coder.oauthCallback"; + +const OAuthCallbackDataSchema = z.object({ + state: z.string(), + code: z.string().nullable(), + error: z.string().nullable(), +}); + +export type OAuthCallbackData = z.infer; + +/** + * Forwards OAuth redirect parameters from the URI handler back to the + * window that initiated the login. Required because the redirect may + * land in a different window than the one that started the flow. + */ +export class OAuthCallback { + private readonly broadcast: WindowBroadcast; + + constructor(secrets: SecretStorage, logger: Logger) { + this.broadcast = new WindowBroadcast( + secrets, + OAUTH_CALLBACK_KEY, + OAuthCallbackDataSchema, + logger, + ); + } + + send(data: OAuthCallbackData): Promise { + return this.broadcast.send(data); + } + + onReceive( + handler: (data: OAuthCallbackData) => void | Promise, + ): Disposable { + return this.broadcast.onReceive(handler); + } +} diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 703cae4e..6942d1c9 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -199,7 +199,7 @@ async function setupDeployment( async function handleOAuthCallback(ctx: UriRouteContext): Promise { const { params, serviceContainer } = ctx; const logger = serviceContainer.getLogger(); - const secretsManager = serviceContainer.getSecretsManager(); + const oauthCallback = serviceContainer.getOAuthCallback(); const code = params.get("code"); const state = params.get("state"); @@ -211,7 +211,7 @@ async function handleOAuthCallback(ctx: UriRouteContext): Promise { } try { - await secretsManager.setOAuthCallback({ state, code, error }); + await oauthCallback.send({ state, code, error }); logger.debug("OAuth callback processed successfully"); } catch (err) { logger.error("Failed to process OAuth callback:", err); diff --git a/src/workspace/duplicateWorkspaceIpc.ts b/src/workspace/duplicateWorkspaceIpc.ts new file mode 100644 index 00000000..f456e24f --- /dev/null +++ b/src/workspace/duplicateWorkspaceIpc.ts @@ -0,0 +1,133 @@ +import crypto from "node:crypto"; +import { z } from "zod"; + +import { WindowBroadcast } from "../ipc/windowBroadcast"; + +import type { Disposable, SecretStorage } from "vscode"; + +import type { Logger } from "../logging/logger"; + +const DEFAULT_PING_TIMEOUT_MS = 1000; + +const REQUEST_KEY = "coder.ipc.req"; +const RESPONSE_KEY = "coder.ipc.res"; + +const PingMessageSchema = z.object({ + type: z.literal("ping"), + id: z.string(), + authority: z.string(), +}); + +const PongMessageSchema = z.object({ + type: z.literal("pong"), + id: z.string(), + sessionId: z.string(), +}); + +const DuplicateMessageSchema = z.object({ + type: z.literal("duplicate"), + id: z.string(), + targetSessionId: z.string(), +}); + +const RequestMessageSchema = z.discriminatedUnion("type", [ + PingMessageSchema, + DuplicateMessageSchema, +]); + +export type PingMessage = z.infer; +export type PongMessage = z.infer; +export type DuplicateMessage = z.infer; +export type RequestMessage = z.infer; + +/** + * Cross-window protocol for the open-workspace flow: + * PING "anyone connected to this authority?" + * PONG "yes, with this sessionId" + * DUPLICATE "sessionId, please duplicate yourself" + * + * The sender shows the prompt locally on first PONG. If the user picks + * Duplicate, it sends DUPLICATE targeted at the responder's sessionId. + */ +export class DuplicateWorkspaceIpc { + private readonly requests: WindowBroadcast; + private readonly responses: WindowBroadcast; + + constructor( + secrets: SecretStorage, + private readonly logger: Logger, + ) { + this.requests = new WindowBroadcast( + secrets, + REQUEST_KEY, + RequestMessageSchema, + logger, + ); + this.responses = new WindowBroadcast( + secrets, + RESPONSE_KEY, + PongMessageSchema, + logger, + ); + } + + /** Send a PING and wait for a PONG within the timeout. */ + sendPing( + authority: string, + timeoutMs = DEFAULT_PING_TIMEOUT_MS, + ): Promise { + const id = crypto.randomUUID(); + + return new Promise((resolve) => { + let settled = false; + + const settle = (result: PongMessage | undefined) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + listener.dispose(); + resolve(result); + }; + + const listener = this.responses.onReceive((msg) => { + if (msg.id === id) { + settle(msg); + } + }); + + const timer = setTimeout(() => settle(undefined), timeoutMs); + + this.requests + .send({ type: "ping", id, authority }) + .then(undefined, (err: unknown) => { + this.logger.error("Failed to send IPC ping", err); + settle(undefined); + }); + }); + } + + async sendPong(pingId: string, sessionId: string): Promise { + await this.responses.send({ + type: "pong", + id: pingId, + sessionId, + }); + } + + /** Ask the window with this sessionId to duplicate itself. */ + async sendDuplicate(targetSessionId: string): Promise { + await this.requests.send({ + type: "duplicate", + id: crypto.randomUUID(), + targetSessionId, + }); + } + + onRequest( + handler: (msg: RequestMessage) => void | Promise, + ): Disposable { + return this.requests.onReceive(handler); + } +} diff --git a/test/unit/ipc/windowBroadcast.test.ts b/test/unit/ipc/windowBroadcast.test.ts new file mode 100644 index 00000000..e72a5a6e --- /dev/null +++ b/test/unit/ipc/windowBroadcast.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; + +import { WindowBroadcast } from "@/ipc/windowBroadcast"; + +import { + InMemorySecretStorage, + createMockLogger, +} from "../../mocks/testHelpers"; + +const TestMessageSchema = z.object({ + kind: z.string(), + value: z.number(), +}); +type TestMessage = z.infer; + +// SecretStorage.onDidChange fires synchronously after store(), but the +// listener body awaits a storage read before invoking the handler. Yield +// once to let those microtasks run. +const flushAsync = () => new Promise((resolve) => setImmediate(resolve)); + +function makeBroadcast(key = "test.channel") { + const secrets = new InMemorySecretStorage(); + const broadcast = new WindowBroadcast( + secrets, + key, + TestMessageSchema, + createMockLogger(), + ); + return { secrets, broadcast }; +} + +describe("WindowBroadcast", () => { + it("delivers messages on its own key and ignores other keys", async () => { + const { secrets, broadcast } = makeBroadcast("my.key"); + const other = new WindowBroadcast( + secrets, + "other.key", + TestMessageSchema, + createMockLogger(), + ); + + const handler = vi.fn(); + broadcast.onReceive(handler); + + await other.send({ kind: "stranger", value: 1 }); + await flushAsync(); + expect(handler).not.toHaveBeenCalled(); + + await broadcast.send({ kind: "self", value: 2 }); + await flushAsync(); + expect(handler).toHaveBeenCalledWith({ kind: "self", value: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("drops messages that fail schema validation", async () => { + const { secrets, broadcast } = makeBroadcast(); + const handler = vi.fn(); + broadcast.onReceive(handler); + + await secrets.store("test.channel", JSON.stringify({ kind: "x" })); + await flushAsync(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("drops malformed JSON without crashing", async () => { + const { secrets, broadcast } = makeBroadcast(); + const handler = vi.fn(); + broadcast.onReceive(handler); + + await secrets.store("test.channel", "{not json"); + await flushAsync(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("stops delivering after dispose", async () => { + const { broadcast } = makeBroadcast(); + const handler = vi.fn(); + const subscription = broadcast.onReceive(handler); + + await broadcast.send({ kind: "before", value: 1 }); + await flushAsync(); + expect(handler).toHaveBeenCalledTimes(1); + + subscription.dispose(); + await broadcast.send({ kind: "after", value: 2 }); + await flushAsync(); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index be9013f3..fa6a2aed 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -6,6 +6,7 @@ import { MementoManager } from "@/core/mementoManager"; import { SecretsManager } from "@/core/secretsManager"; import { getHeaders } from "@/headers"; import { LoginCoordinator } from "@/login/loginCoordinator"; +import { OAuthCallback } from "@/oauth/oauthCallback"; import { maybeAskAuthMethod } from "@/promptUtils"; import { @@ -115,6 +116,7 @@ function createTestContext() { const memento = new InMemoryMemento(); const logger = createMockLogger(); const secretsManager = new SecretsManager(secretStorage, memento, logger); + const oauthCallback = new OAuthCallback(secretStorage, logger); const mementoManager = new MementoManager(memento); const mockCredentialManager = createMockCliCredentialManager(); @@ -123,6 +125,7 @@ function createTestContext() { mementoManager, logger, mockCredentialManager, + oauthCallback, "coder.coder-remote", ); @@ -151,6 +154,7 @@ function createTestContext() { mockConfig, userInteraction, secretsManager, + oauthCallback, mementoManager, mockCredentialManager, coordinator, @@ -302,8 +306,13 @@ describe("LoginCoordinator", () => { }); it("logs warning instead of showing dialog for autoLogin", async () => { - const { mockConfig, secretsManager, mementoManager, mockAuthFailure } = - createTestContext(); + const { + mockConfig, + secretsManager, + oauthCallback, + mementoManager, + mockAuthFailure, + } = createTestContext(); mockConfig.set("coder.tlsCertFile", "/path/to/cert.pem"); mockConfig.set("coder.tlsKeyFile", "/path/to/key.pem"); @@ -313,6 +322,7 @@ describe("LoginCoordinator", () => { mementoManager, logger, createMockCliCredentialManager(), + oauthCallback, "coder.coder-remote", ); diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index ecfed45f..55560d84 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -61,6 +61,7 @@ function createTestContext() { const base = createBaseTestContext(); const authorizer = new OAuthAuthorizer( base.secretsManager, + base.oauthCallback, base.logger, EXTENSION_ID, ); @@ -83,7 +84,7 @@ function createTestContext() { /** Completes login by sending successful OAuth callback */ const completeLogin = async (state: string) => { - await base.secretsManager.setOAuthCallback({ + await base.oauthCallback.send({ state, code: "code", error: null, @@ -111,7 +112,8 @@ async function waitForBrowserToOpen(): Promise<{ describe("OAuthAuthorizer", () => { describe("login flow", () => { it("completes full OAuth login flow successfully", async () => { - const { mockAdapter, secretsManager, authorizer } = createTestContext(); + const { mockAdapter, oauthCallback, secretsManager, authorizer } = + createTestContext(); setupAxiosMockRoutes(mockAdapter, { "/.well-known/oauth-authorization-server": @@ -138,7 +140,7 @@ describe("OAuthAuthorizer", () => { const { state } = await waitForBrowserToOpen(); // Set the callback with the correct state (simulate user clicking authorize) - await secretsManager.setOAuthCallback({ + await oauthCallback.send({ state, code: "auth-code-123", error: null, @@ -156,7 +158,8 @@ describe("OAuthAuthorizer", () => { }); it("uses existing client registration when redirect URI matches", async () => { - const { mockAdapter, secretsManager, authorizer } = createTestContext(); + const { mockAdapter, oauthCallback, secretsManager, authorizer } = + createTestContext(); // Pre-store a client registration with matching redirect URI await secretsManager.setOAuthClientRegistration( @@ -185,7 +188,7 @@ describe("OAuthAuthorizer", () => { const { authUrl, state } = await waitForBrowserToOpen(); expect(authUrl.searchParams.get("client_id")).toBe("existing-client-id"); - await secretsManager.setOAuthCallback({ + await oauthCallback.send({ state, code: "code", error: null, @@ -194,7 +197,8 @@ describe("OAuthAuthorizer", () => { }); it("re-registers client when redirect URI has changed", async () => { - const { mockAdapter, secretsManager, authorizer } = createTestContext(); + const { mockAdapter, oauthCallback, secretsManager, authorizer } = + createTestContext(); // Pre-store a client registration with different redirect URI await secretsManager.setOAuthClientRegistration( @@ -225,7 +229,7 @@ describe("OAuthAuthorizer", () => { const { authUrl, state } = await waitForBrowserToOpen(); expect(authUrl.searchParams.get("client_id")).toBe("new-client-id"); - await secretsManager.setOAuthCallback({ + await oauthCallback.send({ state, code: "code", error: null, @@ -260,14 +264,14 @@ describe("OAuthAuthorizer", () => { describe("callback handling", () => { it("ignores callback with wrong state", async () => { - const { secretsManager, setupOAuthRoutes, startLogin, completeLogin } = + const { oauthCallback, setupOAuthRoutes, startLogin, completeLogin } = createTestContext(); setupOAuthRoutes(); const { loginPromise, state } = await startLogin(); // Send callback with wrong state - should be ignored - await secretsManager.setOAuthCallback({ + await oauthCallback.send({ state: "wrong-state", code: "code", error: null, @@ -287,12 +291,12 @@ describe("OAuthAuthorizer", () => { }); it("rejects on OAuth error callback", async () => { - const { secretsManager, setupOAuthRoutes, startLogin } = + const { oauthCallback, setupOAuthRoutes, startLogin } = createTestContext(); setupOAuthRoutes(); const { loginPromise, state } = await startLogin(); - await secretsManager.setOAuthCallback({ + await oauthCallback.send({ state, code: null, error: "access_denied", @@ -302,12 +306,16 @@ describe("OAuthAuthorizer", () => { }); it("rejects when no code is received", async () => { - const { secretsManager, setupOAuthRoutes, startLogin } = + const { oauthCallback, setupOAuthRoutes, startLogin } = createTestContext(); setupOAuthRoutes(); const { loginPromise, state } = await startLogin(); - await secretsManager.setOAuthCallback({ state, code: null, error: null }); + await oauthCallback.send({ + state, + code: null, + error: null, + }); await expect(loginPromise).rejects.toThrow( "No authorization code received", diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index aa64759a..714c5811 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -3,6 +3,7 @@ import { vi } from "vitest"; import { SecretsManager } from "@/core/secretsManager"; import { getHeaders } from "@/headers"; +import { OAuthCallback } from "@/oauth/oauthCallback"; import { createMockLogger, @@ -128,6 +129,7 @@ export function createBaseTestContext() { const memento = new InMemoryMemento(); const logger = createMockLogger(); const secretsManager = new SecretsManager(secretStorage, memento, logger); + const oauthCallback = new OAuthCallback(secretStorage, logger); /** Sets up default OAuth routes - use explicit routes when asserting on values */ const setupOAuthRoutes = () => { @@ -140,5 +142,11 @@ export function createBaseTestContext() { }); }; - return { mockAdapter, secretsManager, logger, setupOAuthRoutes }; + return { + mockAdapter, + secretsManager, + oauthCallback, + logger, + setupOAuthRoutes, + }; } diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index 849f94e0..bd2dd00b 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { MementoManager } from "@/core/mementoManager"; import { SecretsManager } from "@/core/secretsManager"; +import { OAuthCallback } from "@/oauth/oauthCallback"; import { CALLBACK_PATH } from "@/oauth/utils"; import { maybeAskUrl } from "@/promptUtils"; import { registerUriHandler } from "@/uri/uriHandler"; @@ -67,6 +68,7 @@ function createTestContext() { const memento = new InMemoryMemento(); const logger = createMockLogger(); const secretsManager = new SecretsManager(secretStorage, memento, logger); + const oauthCallback = new OAuthCallback(secretStorage, logger); const loginCoordinator = createMockLoginCoordinator(secretsManager); const mementoManager = new MementoManager(memento); const commands = new MockCommands(); @@ -77,6 +79,7 @@ function createTestContext() { getMementoManager: () => mementoManager, getLoginCoordinator: () => loginCoordinator as unknown as LoginCoordinator, getContextManager: () => new MockContextManager(), + getOAuthCallback: () => oauthCallback, getLogger: () => logger, } as unknown as ServiceContainer; @@ -108,6 +111,7 @@ function createTestContext() { deploymentManager, loginCoordinator, secretsManager, + oauthCallback, logger, showErrorMessage, chatPanelProvider, @@ -358,10 +362,10 @@ describe("uriHandler", () => { } it("stores OAuth callback with code and state", async () => { - const { handleUri, secretsManager } = createTestContext(); + const { handleUri, oauthCallback } = createTestContext(); const callbackPromise = new Promise((resolve) => { - secretsManager.onDidChangeOAuthCallback(resolve); + oauthCallback.onReceive(resolve); }); await handleUri( @@ -377,10 +381,10 @@ describe("uriHandler", () => { }); it("stores OAuth callback with error", async () => { - const { handleUri, secretsManager } = createTestContext(); + const { handleUri, oauthCallback } = createTestContext(); const callbackPromise = new Promise((resolve) => { - secretsManager.onDidChangeOAuthCallback(resolve); + oauthCallback.onReceive(resolve); }); await handleUri( @@ -396,10 +400,10 @@ describe("uriHandler", () => { }); it("does not store callback when state is missing", async () => { - const { handleUri, secretsManager } = createTestContext(); + const { handleUri, oauthCallback } = createTestContext(); let callbackReceived = false; - secretsManager.onDidChangeOAuthCallback(() => { + oauthCallback.onReceive(() => { callbackReceived = true; }); diff --git a/test/unit/workspace/duplicateWorkspaceIpc.test.ts b/test/unit/workspace/duplicateWorkspaceIpc.test.ts new file mode 100644 index 00000000..e3e7189f --- /dev/null +++ b/test/unit/workspace/duplicateWorkspaceIpc.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { DuplicateWorkspaceIpc } from "@/workspace/duplicateWorkspaceIpc"; + +import { + InMemorySecretStorage, + createMockLogger, +} from "../../mocks/testHelpers"; + +function makeIpcPair() { + const secrets = new InMemorySecretStorage(); + return { + secrets, + sender: new DuplicateWorkspaceIpc(secrets, createMockLogger()), + receiver: new DuplicateWorkspaceIpc(secrets, createMockLogger()), + }; +} + +describe("DuplicateWorkspaceIpc", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns the PONG when another window responds to a PING", async () => { + const { sender, receiver } = makeIpcPair(); + receiver.onRequest(async (msg) => { + if (msg.type === "ping") { + await receiver.sendPong(msg.id, "session-abc"); + } + }); + + const promise = sender.sendPing("ssh-remote+my-host", 2000); + await vi.advanceTimersByTimeAsync(50); + const pong = await promise; + + expect(pong).toMatchObject({ + type: "pong", + sessionId: "session-abc", + }); + }); + + it("resolves to undefined when no window answers within the timeout", async () => { + const { sender } = makeIpcPair(); + const promise = sender.sendPing("ssh-remote+my-host", 100); + await vi.advanceTimersByTimeAsync(150); + + expect(await promise).toBeUndefined(); + }); + + it("supports the full ping → pong → duplicate round trip", async () => { + const { secrets, sender } = makeIpcPair(); + const duplicateReceived = vi.fn(); + + const peer = new DuplicateWorkspaceIpc(secrets, createMockLogger()); + peer.onRequest(async (msg) => { + if (msg.type === "ping" && msg.authority === "ssh-remote+host") { + await peer.sendPong(msg.id, "win-a"); + } else if (msg.type === "duplicate" && msg.targetSessionId === "win-a") { + duplicateReceived(); + } + }); + + const promise = sender.sendPing("ssh-remote+host", 2000); + await vi.advanceTimersByTimeAsync(50); + const pong = await promise; + expect(pong).toMatchObject({ sessionId: "win-a" }); + + await sender.sendDuplicate("win-a"); + await vi.advanceTimersByTimeAsync(10); + expect(duplicateReceived).toHaveBeenCalledOnce(); + }); +});