Skip to content
Open
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
12 changes: 11 additions & 1 deletion registry/coder/modules/vscode-desktop-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ tags: [internal, library]

The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily for internal use by Coder to create modules for VSCode-based IDEs.

Wrapper modules can also use the core to pre-install extensions before the first IDE connection. The wrapper remains responsible for supplying its verified remote server CLI, extension storage path, and a finite bootstrap script when the CLI is not available on a new workspace. The core creates no extension installation script when `extensions` is empty.

The dedicated extension script blocks ordinary workspace login by default and has a 30-minute timeout. Coder users can manually bypass startup blocking, and an installation failure leaves the workspace marked incomplete. Repeated starts do not force-update extensions that the remote CLI already recognizes as installed; wrappers can pass an explicit `publisher.extension@version` when they need a specific version.

```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.1.0"
version = "1.2.0"

agent_id = var.agent_id

Expand All @@ -29,5 +33,11 @@ module "vscode-desktop-core" {
folder = var.folder
open_recent = var.open_recent
protocol = "vscode"
config_dir = var.config_dir

extensions = var.extensions
extensions_dir = local.remote_extensions_dir
ide_cli_path = local.remote_ide_cli_path
ide_cli_install_script = local.install_remote_ide_cli
}
```
181 changes: 178 additions & 3 deletions registry/coder/modules/vscode-desktop-core/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, setDefaultTimeout } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
Expand All @@ -8,8 +8,11 @@ import {
removeContainer,
findResourceInstance,
readFileContainer,
writeFileContainer,
} from "~test";

setDefaultTimeout(60 * 1000);

// hardcoded coder_app name in main.tf
const appName = "vscode-desktop";

Expand All @@ -24,6 +27,17 @@ const defaultVariables = {
config_dir: "$HOME/.vscode",
};

const setupVariables = {
...defaultVariables,
extensions: JSON.stringify([
"ms-python.python",
"esbenp.prettier-vscode@12.4.0",
]),
extensions_dir: "$HOME/.ide-server/extensions",
ide_cli_path: "/tmp/fake-ide-cli",
ide_cli_install_script: "#!/usr/bin/env bash\nprintf 'bootstrap complete\\n'",
};

describe("vscode-desktop-core", async () => {
await runTerraformInit(import.meta.dir);

Expand Down Expand Up @@ -76,7 +90,7 @@ describe("vscode-desktop-core", async () => {
it("adds folder but not open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
openRecent: "false",
open_recent: "false",

...defaultVariables,
});
Expand Down Expand Up @@ -176,5 +190,166 @@ describe("vscode-desktop-core", async () => {
} finally {
await removeContainer(id);
}
}, 10000);
});

describe("extension installation", () => {
it("creates no extension script with default inputs", async () => {
const state = await runTerraformApply(import.meta.dir, defaultVariables);

const extensionScripts = state.resources.filter(
(resource) =>
resource.type === "coder_script" &&
resource.name === "install_extensions",
);

expect(extensionScripts).toHaveLength(0);
});

it("creates one finite login-blocking script with wrapper-controlled inputs", async () => {
const state = await runTerraformApply(import.meta.dir, setupVariables);
const extensionInstaller = findResourceInstance(
state,
"coder_script",
"install_extensions",
);

expect(extensionInstaller.run_on_start).toBe(true);
expect(extensionInstaller.start_blocks_login).toBe(true);
expect(extensionInstaller.timeout).toBe(1800);
expect(extensionInstaller.script).toContain(
Buffer.from(setupVariables.ide_cli_path).toString("base64"),
);
expect(extensionInstaller.script).toContain(
Buffer.from(setupVariables.extensions_dir).toString("base64"),
);
expect(extensionInstaller.script).toContain(
Buffer.from(setupVariables.ide_cli_install_script).toString("base64"),
);
expect(extensionInstaller.script).not.toContain(".vscode-server");
expect(extensionInstaller.script).not.toContain(".cursor-server");
expect(extensionInstaller.script).not.toContain(".windsurf-server");
});

it("installs each extension separately and remains idempotent", async () => {
const state = await runTerraformApply(import.meta.dir, setupVariables);
const setupScript = findResourceInstance(
state,
"coder_script",
"install_extensions",
).script;
const id = await runContainer("node:22-bookworm-slim");

try {
await writeFileContainer(
id,
setupVariables.ide_cli_path,
`#!/bin/sh
printf '%s\\n' "$@" >> /tmp/ide-cli-args
`,
{ user: "root" },
);
await execContainer(
id,
["chmod", "755", setupVariables.ide_cli_path],
["--user", "root"],
);

const firstRun = await execContainer(id, ["bash", "-c", setupScript]);
expect(firstRun.exitCode).toBe(0);
expect(firstRun.stdout).toContain("bootstrap complete");

const secondRun = await execContainer(id, ["bash", "-c", setupScript]);
expect(secondRun.exitCode).toBe(0);

const argumentsLog = await readFileContainer(id, "/tmp/ide-cli-args");
const expectedRun = [
"--install-extension",
"ms-python.python",
"--extensions-dir",
"/root/.ide-server/extensions",
"--install-extension",
"esbenp.prettier-vscode@12.4.0",
"--extensions-dir",
"/root/.ide-server/extensions",
];
expect(argumentsLog.trim().split("\n")).toEqual([
...expectedRun,
...expectedRun,
]);
} finally {
await removeContainer(id);
}
}, 20000);

it("fails clearly when the IDE CLI rejects an extension", async () => {
const state = await runTerraformApply(import.meta.dir, {
...setupVariables,
extensions: JSON.stringify(["invalid.extension"]),
});
const setupScript = findResourceInstance(
state,
"coder_script",
"install_extensions",
).script;
const id = await runContainer("node:22-bookworm-slim");

try {
await writeFileContainer(
id,
setupVariables.ide_cli_path,
`#!/bin/sh
printf 'extension installation failed\\n' >&2
exit 23
`,
{ user: "root" },
);
await execContainer(
id,
["chmod", "755", setupVariables.ide_cli_path],
["--user", "root"],
);

const result = await execContainer(id, ["bash", "-c", setupScript]);
expect(result.exitCode).toBe(23);
expect(result.stdout).toContain(
"Installing extension invalid.extension...",
);
expect(result.stderr).toContain("extension installation failed");
} finally {
await removeContainer(id);
}
}, 20000);

it("fails before bootstrap when a required wrapper path is empty", async () => {
const state = await runTerraformApply(import.meta.dir, {
...setupVariables,
extensions_dir: "",
ide_cli_install_script: "touch /tmp/bootstrap-ran",
});
const setupScript = findResourceInstance(
state,
"coder_script",
"install_extensions",
).script;
const id = await runContainer("node:22-bookworm-slim");

try {
const result = await execContainer(id, ["bash", "-c", setupScript]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
"extensions_dir is required when extensions are configured.",
);

const bootstrapMarker = await execContainer(id, [
"test",
"!",
"-e",
"/tmp/bootstrap-ran",
]);
expect(bootstrapMarker.exitCode).toBe(0);
} finally {
await removeContainer(id);
}
}, 20000);
});
});
52 changes: 52 additions & 0 deletions registry/coder/modules/vscode-desktop-core/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ variable "mcp_config" {
default = null
}

variable "extensions" {
description = "Extension IDs to pre-install on the remote workspace host."
type = list(string)
default = []

validation {
condition = alltrue([for extension in var.extensions : trimspace(extension) != ""])
error_message = "extensions must not contain empty extension IDs."
}
}

variable "extensions_dir" {
description = "Remote extension directory supplied by the IDE wrapper."
type = string
default = ""
}

variable "ide_cli_path" {
description = "Remote IDE server CLI supplied by the IDE wrapper."
type = string
default = ""
}

variable "ide_cli_install_script" {
description = "Internal wrapper-provided finite script that makes ide_cli_path available."
type = string
default = null
}

variable "protocol" {
type = string
description = "The URI protocol the IDE."
Expand Down Expand Up @@ -72,6 +101,29 @@ variable "coder_app_group" {
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}

locals {
install_extensions_script = length(var.extensions) > 0 ? templatefile(
"${path.module}/scripts/install-extensions.sh.tftpl",
{
IDE_CLI_INSTALL_SCRIPT_B64 = var.ide_cli_install_script != null ? base64encode(var.ide_cli_install_script) : ""
EXTENSIONS_B64_LINES = join("\n", [for extension in var.extensions : base64encode(extension)])
EXTENSIONS_DIR_B64 = base64encode(var.extensions_dir)
IDE_CLI_PATH_B64 = base64encode(var.ide_cli_path)
},
) : ""
}

resource "coder_script" "install_extensions" {
count = length(var.extensions) > 0 ? 1 : 0
agent_id = var.agent_id
display_name = "${var.coder_app_display_name} Extensions"
icon = var.coder_app_icon
run_on_start = true
start_blocks_login = true
timeout = 1800
script = local.install_extensions_script
}

resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
Expand Down
65 changes: 65 additions & 0 deletions registry/coder/modules/vscode-desktop-core/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
mock_provider "coder" {}

variables {
agent_id = "test-agent"

coder_app_icon = "/icon/code.svg"
coder_app_slug = "test-ide"
coder_app_display_name = "Test IDE"

protocol = "test-ide"
config_dir = "$HOME/.test-ide"
}

run "defaults_create_no_extension_script" {
command = plan

assert {
condition = length(coder_script.install_extensions) == 0
error_message = "Default inputs must not create an extension installation script."
}
}

run "extensions_create_one_blocking_script" {
command = plan

variables {
extensions = ["ms-python.python"]
extensions_dir = "$HOME/.test-ide-server/extensions"
ide_cli_path = "$HOME/.coder-modules/coder/test-ide/server/bin/test-ide-server"
}

assert {
condition = length(coder_script.install_extensions) == 1
error_message = "Extensions must create one installation script."
}

assert {
condition = coder_script.install_extensions[0].start_blocks_login
error_message = "The finite extension installation script must block ordinary login by default."
}

assert {
condition = coder_script.install_extensions[0].timeout == 1800
error_message = "The extension installation script must have a finite timeout."
}

assert {
condition = !strcontains(coder_script.install_extensions[0].script, "--force")
error_message = "Extension installation must not force updates on every workspace start."
}
}

run "extensions_reject_empty_entries" {
command = plan

variables {
extensions = [""]
extensions_dir = "$HOME/.test-ide-server/extensions"
ide_cli_path = "$HOME/.coder-modules/coder/test-ide/server/bin/test-ide-server"
}

expect_failures = [
var.extensions,
]
}
Loading
Loading