diff --git a/registry/coder/modules/vscode-desktop-core/README.md b/registry/coder/modules/vscode-desktop-core/README.md index 837ff3491..939f07d27 100644 --- a/registry/coder/modules/vscode-desktop-core/README.md +++ b/registry/coder/modules/vscode-desktop-core/README.md @@ -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 @@ -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 } ``` diff --git a/registry/coder/modules/vscode-desktop-core/main.test.ts b/registry/coder/modules/vscode-desktop-core/main.test.ts index 436159784..f5ea95e0e 100644 --- a/registry/coder/modules/vscode-desktop-core/main.test.ts +++ b/registry/coder/modules/vscode-desktop-core/main.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, setDefaultTimeout } from "bun:test"; import { runTerraformApply, runTerraformInit, @@ -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"; @@ -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); @@ -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, }); @@ -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); + }); }); diff --git a/registry/coder/modules/vscode-desktop-core/main.tf b/registry/coder/modules/vscode-desktop-core/main.tf index 055647d2c..5ada0614c 100644 --- a/registry/coder/modules/vscode-desktop-core/main.tf +++ b/registry/coder/modules/vscode-desktop-core/main.tf @@ -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." @@ -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 diff --git a/registry/coder/modules/vscode-desktop-core/main.tftest.hcl b/registry/coder/modules/vscode-desktop-core/main.tftest.hcl new file mode 100644 index 000000000..96c13d437 --- /dev/null +++ b/registry/coder/modules/vscode-desktop-core/main.tftest.hcl @@ -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, + ] +} diff --git a/registry/coder/modules/vscode-desktop-core/scripts/install-extensions.sh.tftpl b/registry/coder/modules/vscode-desktop-core/scripts/install-extensions.sh.tftpl new file mode 100644 index 000000000..aeca65274 --- /dev/null +++ b/registry/coder/modules/vscode-desktop-core/scripts/install-extensions.sh.tftpl @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +expand_home() { + case "$1" in + '$HOME') + printf '%s\n' "$HOME" + ;; + '$HOME/'*) + printf '%s/%s\n' "$HOME" "$${1#\$HOME/}" + ;; + *) + printf '%s\n' "$1" + ;; + esac +} + +decode_path() { + local encoded_path="$1" + local decoded_path + + decoded_path="$(printf '%s' "$encoded_path" | base64 -d)" + expand_home "$decoded_path" +} + +IDE_CLI_PATH="$(decode_path '${IDE_CLI_PATH_B64}')" +EXTENSIONS_DIR="$(decode_path '${EXTENSIONS_DIR_B64}')" +IDE_CLI_INSTALL_SCRIPT_B64='${IDE_CLI_INSTALL_SCRIPT_B64}' + +if [ -z "$IDE_CLI_PATH" ]; then + printf 'ide_cli_path is required when extensions are configured.\n' >&2 + exit 1 +fi +if [ -z "$EXTENSIONS_DIR" ]; then + printf 'extensions_dir is required when extensions are configured.\n' >&2 + exit 1 +fi + +if [ -n "$IDE_CLI_INSTALL_SCRIPT_B64" ]; then + bootstrap_script="$(mktemp)" + trap 'rm -f "$bootstrap_script"' EXIT + printf '%s' "$IDE_CLI_INSTALL_SCRIPT_B64" | base64 -d > "$bootstrap_script" + chmod 700 "$bootstrap_script" + "$bootstrap_script" +fi + +if [ ! -x "$IDE_CLI_PATH" ]; then + printf 'IDE CLI is not executable: %s\n' "$IDE_CLI_PATH" >&2 + exit 1 +fi + +mkdir -p "$EXTENSIONS_DIR" + +while IFS= read -r encoded_extension; do + [ -n "$encoded_extension" ] || continue + extension="$(printf '%s' "$encoded_extension" | base64 -d)" + printf 'Installing extension %s...\n' "$extension" + "$IDE_CLI_PATH" \ + --install-extension "$extension" \ + --extensions-dir "$EXTENSIONS_DIR" +done <<'EXTENSIONS' +${EXTENSIONS_B64_LINES} +EXTENSIONS