Skip to content

feat: add ability to attach to devcontainers #463

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { makeCoderSdk, needToken } from "./api"
import { extractAgents } from "./api-helper"
import { CertificateError } from "./error"
import { Storage } from "./storage"
import { AuthorityPrefix, toSafeHost } from "./util"
import { toRemoteAuthority, toSafeHost } from "./util"
import { OpenableTreeItem } from "./workspacesProvider"

export class Commands {
Expand Down Expand Up @@ -499,6 +499,26 @@ export class Commands {
await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
}

/**
* Open a devcontainer from a workspace belonging to the currently logged-in deployment.
*
* Throw if not logged into a deployment.
*/
public async openDevContainer(...args: string[]): Promise<void> {
const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL
if (!baseUrl) {
throw new Error("You are not logged in")
}

const workspaceOwner = args[0] as string
const workspaceName = args[1] as string
const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet.
const devContainerName = args[3] as string
Comment on lines +515 to +516
Copy link
Preview

Copilot AI Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension passes a workspaceAgent parameter for the openDevContainer command, but this parameter is being discarded by explicitly setting workspaceAgent to undefined. Consider either using args[2] if provided or updating the extension to no longer supply the parameter.

Suggested change
const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet.
const devContainerName = args[3] as string
const workspaceAgent = args[2] as WorkspaceAgent | undefined // Use args[2] if provided.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have precedent for doing this with the openWorkspace flow

const devContainerFolder = args[4] as string

await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder)
}

/**
* Update the current workspace. If there is no active workspace connection,
* this is a no-op.
Expand Down Expand Up @@ -536,10 +556,7 @@ async function openWorkspace(
) {
// A workspace can have multiple agents, but that's handled
// when opening a workspace unless explicitly specified.
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
if (workspaceAgent) {
remoteAuthority += `.${workspaceAgent}`
}
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

let newWindow = true
// Open in the existing window if no workspaces are open.
Expand Down Expand Up @@ -598,3 +615,32 @@ async function openWorkspace(
reuseWindow: !newWindow,
})
}

async function openDevContainer(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
devContainerName: string,
devContainerFolder: string,
) {
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)

const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex")
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}`

let newWindow = true
if (!vscode.workspace.workspaceFolders?.length) {
newWindow = false
}

await vscode.commands.executeCommand(
"vscode.openFolder",
vscode.Uri.from({
scheme: "vscode-remote",
authority: devContainerAuthority,
path: devContainerFolder,
}),
newWindow,
)
}
56 changes: 56 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,61 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
await storage.configureCli(toSafeHost(url), url, token)

vscode.commands.executeCommand("coder.open", owner, workspace, agent, folder, openRecent)
} else if (uri.path === "/openDevContainer") {
const workspaceOwner = params.get("owner")
const workspaceName = params.get("workspace")
const workspaceAgent = params.get("agent")
const devContainerName = params.get("devContainerName")
const devContainerFolder = params.get("devContainerFolder")

if (!workspaceOwner) {
throw new Error("workspace owner must be specified as a query parameter")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to change in this PR since this is a pattern that already existed, but at some point I wonder if we should catch handler errors and show them in an error notification. Currently the only way to see them seems to be to go to the extension's features > runtime status > uncaught errors.

}

if (!workspaceName) {
throw new Error("workspace name must be specified as a query parameter")
}

if (!devContainerName) {
throw new Error("dev container name must be specified as a query parameter")
}

if (!devContainerFolder) {
throw new Error("dev container folder must be specified as a query parameter")
}

// We are not guaranteed that the URL we currently have is for the URL
// this workspace belongs to, or that we even have a URL at all (the
// queries will default to localhost) so ask for it if missing.
// Pre-populate in case we do have the right URL so the user can just
// hit enter and move on.
const url = await commands.maybeAskUrl(params.get("url"), storage.getUrl())
if (url) {
restClient.setHost(url)
await storage.setUrl(url)
} else {
throw new Error("url must be provided or specified as a query parameter")
}

// If the token is missing we will get a 401 later and the user will be
// prompted to sign in again, so we do not need to ensure it is set now.
// For non-token auth, we write a blank token since the `vscodessh`
// command currently always requires a token file. However, if there is
// a query parameter for non-token auth go ahead and use it anyway; all
// that really matters is the file is created.
const token = needToken() ? params.get("token") : (params.get("token") ?? "")

// Store on disk to be used by the cli.
await storage.configureCli(toSafeHost(url), url, token)

vscode.commands.executeCommand(
"coder.openDevContainer",
workspaceOwner,
workspaceName,
workspaceAgent,
devContainerName,
devContainerFolder,
)
} else {
throw new Error(`Unknown path ${uri.path}`)
}
Expand All @@ -123,6 +178,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
vscode.commands.registerCommand("coder.login", commands.login.bind(commands))
vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands))
vscode.commands.registerCommand("coder.open", commands.open.bind(commands))
vscode.commands.registerCommand("coder.openDevContainer", commands.openDevContainer.bind(commands))
vscode.commands.registerCommand("coder.openFromSidebar", commands.openFromSidebar.bind(commands))
vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands))
vscode.commands.registerCommand("coder.createWorkspace", commands.createWorkspace.bind(commands))
Expand Down
13 changes: 13 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
}
}

export function toRemoteAuthority(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
): string {
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
if (workspaceAgent) {
remoteAuthority += `.${workspaceAgent}`
}
return remoteAuthority
}

/**
* Given a URL, return the host in a format that is safe to write.
*/
Expand Down