Skip to content

Commit 5c66430

Browse files
committed
Merge branch 'main' into bcpeinhardt/ai-agent-session-in-vscode
2 parents b1e281c + 53dae54 commit 5c66430

File tree

8 files changed

+695
-425
lines changed

8 files changed

+695
-425
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
## Unreleased
44

5+
## [v1.7.0](https://github.com/coder/vscode-coder/releases/tag/v1.7.0) (2025-04-03)
6+
7+
### Added
8+
9+
- Add new `/openDevContainer` path, similar to the `/open` path, except this
10+
allows connecting to a dev container inside a workspace. For now, the dev
11+
container must already be running for this to work.
12+
13+
### Fixed
14+
15+
- When not using token authentication, avoid setting `undefined` for the token
16+
header, as Node will throw an error when headers are undefined. Now, we will
17+
not set any header at all.
18+
519
## [v1.6.0](https://github.com/coder/vscode-coder/releases/tag/v1.6.0) (2025-04-01)
620

721
### Added

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"displayName": "Coder",
55
"description": "Open any workspace with a single click.",
66
"repository": "https://github.com/coder/vscode-coder",
7-
"version": "1.6.0",
7+
"version": "1.7.0",
88
"engines": {
99
"vscode": "^1.73.0"
1010
},
@@ -283,16 +283,16 @@
283283
"devDependencies": {
284284
"@types/eventsource": "^3.0.0",
285285
"@types/glob": "^7.1.3",
286-
"@types/node": "^18.0.0",
286+
"@types/node": "^22.14.0",
287287
"@types/node-forge": "^1.3.11",
288288
"@types/ua-parser-js": "^0.7.39",
289289
"@types/vscode": "^1.73.0",
290-
"@types/ws": "^8.5.11",
290+
"@types/ws": "^8.18.1",
291291
"@typescript-eslint/eslint-plugin": "^6.21.0",
292292
"@typescript-eslint/parser": "^6.21.0",
293293
"@vscode/test-electron": "^2.4.1",
294294
"@vscode/vsce": "^2.21.1",
295-
"bufferutil": "^4.0.8",
295+
"bufferutil": "^4.0.9",
296296
"coder": "https://github.com/coder/coder#main",
297297
"dayjs": "^1.11.13",
298298
"eslint": "^8.57.1",
@@ -309,22 +309,22 @@
309309
"utf-8-validate": "^6.0.5",
310310
"vitest": "^0.34.6",
311311
"vscode-test": "^1.5.0",
312-
"webpack": "^5.94.0",
312+
"webpack": "^5.98.0",
313313
"webpack-cli": "^5.1.4"
314314
},
315315
"dependencies": {
316316
"axios": "1.8.4",
317317
"date-fns": "^3.6.0",
318-
"eventsource": "^3.0.5",
318+
"eventsource": "^3.0.6",
319319
"find-process": "^1.4.7",
320320
"jsonc-parser": "^3.3.1",
321321
"memfs": "^4.9.3",
322322
"node-forge": "^1.3.1",
323-
"pretty-bytes": "^6.0.0",
323+
"pretty-bytes": "^6.1.1",
324324
"proxy-agent": "^6.4.0",
325325
"semver": "^7.6.2",
326326
"ua-parser-js": "^1.0.38",
327-
"ws": "^8.18.0",
327+
"ws": "^8.18.1",
328328
"zod": "^3.23.8"
329329
},
330330
"resolutions": {

src/api.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { getProxyForUrl } from "./proxy"
1313
import { Storage } from "./storage"
1414
import { expandPath } from "./util"
1515

16+
export const coderSessionTokenHeader = "Coder-Session-Token"
17+
1618
/**
1719
* Return whether the API will need a token for authorization.
1820
* If mTLS is in use (as specified by the cert or key files being set) then
@@ -242,14 +244,15 @@ export async function waitForBuild(
242244
const baseUrl = new URL(baseUrlRaw)
243245
const proto = baseUrl.protocol === "https:" ? "wss:" : "ws:"
244246
const socketUrlRaw = `${proto}//${baseUrl.host}${path}`
247+
const token = restClient.getAxiosInstance().defaults.headers.common[coderSessionTokenHeader] as string | undefined
245248
const socket = new ws.WebSocket(new URL(socketUrlRaw), {
246-
headers: {
247-
"Coder-Session-Token": restClient.getAxiosInstance().defaults.headers.common["Coder-Session-Token"] as
248-
| string
249-
| undefined,
250-
},
251-
followRedirects: true,
252249
agent: agent,
250+
followRedirects: true,
251+
headers: token
252+
? {
253+
[coderSessionTokenHeader]: token,
254+
}
255+
: undefined,
253256
})
254257
socket.binaryType = "nodebuffer"
255258
socket.on("message", (data) => {

src/commands.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { makeCoderSdk, needToken } from "./api"
66
import { extractAgents } from "./api-helper"
77
import { CertificateError } from "./error"
88
import { Storage } from "./storage"
9-
import { AuthorityPrefix, toSafeHost } from "./util"
9+
import { toRemoteAuthority, toSafeHost } from "./util"
1010
import { OpenableTreeItem } from "./workspacesProvider"
1111
import path from "node:path"
1212

@@ -543,6 +543,26 @@ export class Commands {
543543
await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
544544
}
545545

546+
/**
547+
* Open a devcontainer from a workspace belonging to the currently logged-in deployment.
548+
*
549+
* Throw if not logged into a deployment.
550+
*/
551+
public async openDevContainer(...args: string[]): Promise<void> {
552+
const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL
553+
if (!baseUrl) {
554+
throw new Error("You are not logged in")
555+
}
556+
557+
const workspaceOwner = args[0] as string
558+
const workspaceName = args[1] as string
559+
const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet.
560+
const devContainerName = args[3] as string
561+
const devContainerFolder = args[4] as string
562+
563+
await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder)
564+
}
565+
546566
/**
547567
* Update the current workspace. If there is no active workspace connection,
548568
* this is a no-op.
@@ -580,10 +600,7 @@ async function openWorkspace(
580600
) {
581601
// A workspace can have multiple agents, but that's handled
582602
// when opening a workspace unless explicitly specified.
583-
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
584-
if (workspaceAgent) {
585-
remoteAuthority += `.${workspaceAgent}`
586-
}
603+
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)
587604

588605
let newWindow = true
589606
// Open in the existing window if no workspaces are open.
@@ -642,3 +659,32 @@ async function openWorkspace(
642659
reuseWindow: !newWindow,
643660
})
644661
}
662+
663+
async function openDevContainer(
664+
baseUrl: string,
665+
workspaceOwner: string,
666+
workspaceName: string,
667+
workspaceAgent: string | undefined,
668+
devContainerName: string,
669+
devContainerFolder: string,
670+
) {
671+
const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent)
672+
673+
const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex")
674+
const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}`
675+
676+
let newWindow = true
677+
if (!vscode.workspace.workspaceFolders?.length) {
678+
newWindow = false
679+
}
680+
681+
await vscode.commands.executeCommand(
682+
"vscode.openFolder",
683+
vscode.Uri.from({
684+
scheme: "vscode-remote",
685+
authority: devContainerAuthority,
686+
path: devContainerFolder,
687+
}),
688+
newWindow,
689+
)
690+
}

src/extension.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
2222
// Cursor and VSCode are covered by ms remote, and the only other is windsurf for now
2323
// Means that vscodium is not supported by this for now
2424
const remoteSSHExtension =
25+
vscode.extensions.getExtension("jeanp413.open-remote-ssh") ||
2526
vscode.extensions.getExtension("codeium.windsurf-remote-openssh") ||
2627
vscode.extensions.getExtension("ms-vscode-remote.remote-ssh")
2728
if (!remoteSSHExtension) {
@@ -111,6 +112,61 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
111112
await storage.configureCli(toSafeHost(url), url, token)
112113

113114
vscode.commands.executeCommand("coder.open", owner, workspace, agent, folder, openRecent)
115+
} else if (uri.path === "/openDevContainer") {
116+
const workspaceOwner = params.get("owner")
117+
const workspaceName = params.get("workspace")
118+
const workspaceAgent = params.get("agent")
119+
const devContainerName = params.get("devContainerName")
120+
const devContainerFolder = params.get("devContainerFolder")
121+
122+
if (!workspaceOwner) {
123+
throw new Error("workspace owner must be specified as a query parameter")
124+
}
125+
126+
if (!workspaceName) {
127+
throw new Error("workspace name must be specified as a query parameter")
128+
}
129+
130+
if (!devContainerName) {
131+
throw new Error("dev container name must be specified as a query parameter")
132+
}
133+
134+
if (!devContainerFolder) {
135+
throw new Error("dev container folder must be specified as a query parameter")
136+
}
137+
138+
// We are not guaranteed that the URL we currently have is for the URL
139+
// this workspace belongs to, or that we even have a URL at all (the
140+
// queries will default to localhost) so ask for it if missing.
141+
// Pre-populate in case we do have the right URL so the user can just
142+
// hit enter and move on.
143+
const url = await commands.maybeAskUrl(params.get("url"), storage.getUrl())
144+
if (url) {
145+
restClient.setHost(url)
146+
await storage.setUrl(url)
147+
} else {
148+
throw new Error("url must be provided or specified as a query parameter")
149+
}
150+
151+
// If the token is missing we will get a 401 later and the user will be
152+
// prompted to sign in again, so we do not need to ensure it is set now.
153+
// For non-token auth, we write a blank token since the `vscodessh`
154+
// command currently always requires a token file. However, if there is
155+
// a query parameter for non-token auth go ahead and use it anyway; all
156+
// that really matters is the file is created.
157+
const token = needToken() ? params.get("token") : (params.get("token") ?? "")
158+
159+
// Store on disk to be used by the cli.
160+
await storage.configureCli(toSafeHost(url), url, token)
161+
162+
vscode.commands.executeCommand(
163+
"coder.openDevContainer",
164+
workspaceOwner,
165+
workspaceName,
166+
workspaceAgent,
167+
devContainerName,
168+
devContainerFolder,
169+
)
114170
} else {
115171
throw new Error(`Unknown path ${uri.path}`)
116172
}
@@ -123,6 +179,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
123179
vscode.commands.registerCommand("coder.login", commands.login.bind(commands))
124180
vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands))
125181
vscode.commands.registerCommand("coder.open", commands.open.bind(commands))
182+
vscode.commands.registerCommand("coder.openDevContainer", commands.openDevContainer.bind(commands))
126183
vscode.commands.registerCommand("coder.openFromSidebar", commands.openFromSidebar.bind(commands))
127184
vscode.commands.registerCommand("coder.openAppStatus", commands.openAppStatus.bind(commands))
128185
vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands))

src/inbox.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Workspace, GetInboxNotificationResponse } from "coder/site/src/api/type
33
import { ProxyAgent } from "proxy-agent"
44
import * as vscode from "vscode"
55
import { WebSocket } from "ws"
6+
import { coderSessionTokenHeader } from "./api"
67
import { errToStr } from "./api-helper"
78
import { type Storage } from "./storage"
89

@@ -37,15 +38,15 @@ export class Inbox implements vscode.Disposable {
3738
const socketProto = baseUrl.protocol === "https:" ? "wss:" : "ws:"
3839
const socketUrl = `${socketProto}//${baseUrl.host}/api/v2/notifications/inbox/watch?format=plaintext&templates=${watchTemplatesParam}&targets=${watchTargetsParam}`
3940

40-
const coderSessionTokenHeader = "Coder-Session-Token"
41+
const token = restClient.getAxiosInstance().defaults.headers.common[coderSessionTokenHeader] as string | undefined
4142
this.#socket = new WebSocket(new URL(socketUrl), {
42-
followRedirects: true,
4343
agent: httpAgent,
44-
headers: {
45-
[coderSessionTokenHeader]: restClient.getAxiosInstance().defaults.headers.common[coderSessionTokenHeader] as
46-
| string
47-
| undefined,
48-
},
44+
followRedirects: true,
45+
headers: token
46+
? {
47+
[coderSessionTokenHeader]: token,
48+
}
49+
: undefined,
4950
})
5051

5152
this.#socket.on("open", () => {

src/util.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
6161
}
6262
}
6363

64+
export function toRemoteAuthority(
65+
baseUrl: string,
66+
workspaceOwner: string,
67+
workspaceName: string,
68+
workspaceAgent: string | undefined,
69+
): string {
70+
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`
71+
if (workspaceAgent) {
72+
remoteAuthority += `.${workspaceAgent}`
73+
}
74+
return remoteAuthority
75+
}
76+
6477
/**
6578
* Given a URL, return the host in a format that is safe to write.
6679
*/

0 commit comments

Comments
 (0)