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
36 changes: 32 additions & 4 deletions registry/coder/modules/vscode-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
}
```
Expand All @@ -29,7 +29,7 @@ module "vscode" {
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
Expand All @@ -43,7 +43,7 @@ Use `extensions` to install VS Code extension IDs before the first ordinary VS C
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id

extensions = [
Expand All @@ -55,4 +55,32 @@ module "vscode" {

The installation blocks ordinary workspace login for up to 30 minutes so extensions are ready before VS Code Desktop connects. A download, extraction, validation, or extension installation failure remains visible in the Coder startup logs. Later workspace starts reuse the existing VS Code Server CLI and do not force extension updates.

The workspace image must provide Bash, `base64`, `sed`, `tar`, and either `curl` or `wget`. The workspace also needs HTTPS egress to the VS Code update and artifact hosts. Extension availability and compatibility depend on the Visual Studio Marketplace; use `publisher.extension@version` to request a specific version.
### Pre-apply machine settings

Use `settings` to configure machine-level settings on the remote workspace host before the user connects. The module creates or merges these settings into `~/.vscode-server/data/Machine/settings.json`.

```tf
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.4.0"
agent_id = coder_agent.main.id

extensions = [
"ms-python.python",
"esbenp.prettier-vscode@12.4.0",
]

settings = {
"editor.fontSize" = 14
"editor.formatOnSave" = true
"workbench.colorCustomizations" = {
"editor.background" = "#1e1e1e"
}
}
}
```

Existing settings in `settings.json` (including JSONC comments and trailing commas) are preserved, and user symlinks are respected and atomically updated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified: comments are not preserved. The pipeline strips comments and trailing commas and rewrites the file with json.dump, so the output is plain JSON, and users lose comments they may have in a dotfiles-managed settings.json. Suggested reword so the docs match behavior:

Suggested change
Existing settings in `settings.json` (including JSONC comments and trailing commas) are preserved, and user symlinks are respected and atomically updated.
Existing keys and values in `settings.json` are preserved and merged on startup. The file is rewritten as plain JSON, so JSONC comments and trailing commas are accepted but not kept. Symlinks are resolved and their targets are updated atomically.


The workspace image must provide Bash, `base64`, `sed`, `tar`, and either `curl` or `wget`. Merging an existing settings file requires `python3`. Extension availability and compatibility depend on the Visual Studio Marketplace; use `publisher.extension@version` to request a specific version.
40 changes: 40 additions & 0 deletions registry/coder/modules/vscode-desktop/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,44 @@ describe("vscode-desktop", async () => {
await removeContainer(id);
}
});

it("does not create settings script with default inputs", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
const settingsScripts = state.resources.filter(
(resource) =>
resource.type === "coder_script" &&
resource.name === "apply_settings",
);
expect(settingsScripts).toHaveLength(0);
});

it("creates login-blocking settings script when settings are configured", async () => {
const settings = {
"editor.fontSize": 14,
"editor.formatOnSave": true,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
settings: JSON.stringify(settings),
});
const settingsScript = findResourceInstance(
state,
"coder_script",
"apply_settings",
);

expect(settingsScript.start_blocks_login).toBe(true);
expect(settingsScript.timeout).toBe(300);
expect(settingsScript.script).toContain(
Buffer.from(JSON.stringify(settings)).toString("base64"),
);
expect(settingsScript.script).toContain(
Buffer.from(
"$HOME/.vscode-server/data/Machine/settings.json",
).toString("base64"),
);
});
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Trailing blank line at EOF, Prettier flags this file (CI check failure). Empty suggestion removes the line:

Suggested change

31 changes: 31 additions & 0 deletions registry/coder/modules/vscode-desktop/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,40 @@ variable "extensions" {
}
}

variable "settings" {
type = any
description = "A map of settings to apply to VS Code Desktop's Machine settings. These settings are merged with any existing machine settings on startup."
default = {}
}
Comment on lines +52 to +56

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

settings has no plan-time guard. A string value passes local.has_settings (verified: "foo" != {} is true in Terraform) and then either writes an invalid settings file or crashes merge-settings.py at runtime, blocking login. This validation rejects non-maps at plan time:

Suggested change
variable "settings" {
type = any
description = "A map of settings to apply to VS Code Desktop's Machine settings. These settings are merged with any existing machine settings on startup."
default = {}
}
variable "settings" {
description = "A map of settings to apply to VS Code Desktop's Machine settings. These settings are merged with any existing machine settings on startup."
type = any
default = {}
validation {
condition = var.settings == null ? true : can([for key, value in var.settings : value])
error_message = "settings must be a map of setting names to values."
}
}

Note map(any) is not a substitute: Terraform rejects mixed-type maps like the README example with "all map elements must have the same type".


variable "settings_file" {
type = string
description = "Path to the settings.json file on the remote workspace host."
default = "$HOME/.vscode-server/data/Machine/settings.json"
}

locals {
module_directory = "$HOME/.coder-modules/coder/vscode-desktop"
server_directory = "${local.module_directory}/server"
has_settings = var.settings != null && var.settings != {}
}

resource "coder_script" "apply_settings" {
count = local.has_settings ? 1 : 0
agent_id = var.agent_id
display_name = "VS Code Desktop Settings"
icon = "/icon/code.svg"
run_on_start = true
start_blocks_login = true
timeout = 300
script = templatefile(
"${path.module}/scripts/apply-settings.sh.tftpl",
{
SETTINGS_B64 = base64encode(jsonencode(var.settings))
SETTINGS_FILE_B64 = base64encode(var.settings_file)
SCRIPTS_DIR = "${path.module}/scripts"
},
)
}

module "vscode-desktop-core" {
Expand Down
34 changes: 34 additions & 0 deletions registry/coder/modules/vscode-desktop/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,37 @@ run "extensions_reject_empty_ids" {
var.extensions,
]
}

run "settings_defaults_create_no_script" {
command = plan

assert {
condition = length(coder_script.apply_settings) == 0
error_message = "Default inputs must not create a settings script."
}
}

run "settings_create_script" {
command = plan

variables {
settings = {
"editor.fontSize" = 14
}
}

assert {
condition = length(coder_script.apply_settings) == 1
error_message = "Configured settings must create one settings script."
}

assert {
condition = coder_script.apply_settings[0].start_blocks_login
error_message = "Settings script must block login by default."
}

assert {
condition = coder_script.apply_settings[0].timeout == 300
error_message = "Settings script must have a finite timeout."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/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"
}

SETTINGS_FILE="$(decode_path '${SETTINGS_FILE_B64}')"

if [ -z "$SETTINGS_FILE" ]; then
printf 'settings_file is required when settings are configured.\n' >&2
exit 1
fi

if [ -L "$SETTINGS_FILE" ]; then
if ! command -v python3 > /dev/null 2>&1; then
printf 'Resolving a symlinked IDE settings file requires python3.\n' >&2
exit 1
fi

SETTINGS_FILE="$(python3 - "$SETTINGS_FILE" << 'PY'
${file("${SCRIPTS_DIR}/resolve-settings-path.py")}
PY
)"
fi

settings_directory="$(dirname "$SETTINGS_FILE")"
mkdir -p "$settings_directory"

desired_settings="$(mktemp "$settings_directory/.coder-settings.desired.XXXXXX")"
merged_settings="$(mktemp "$settings_directory/.coder-settings.merged.XXXXXX")"
trap 'rm -f "$desired_settings" "$merged_settings"' EXIT

printf '%s' '${SETTINGS_B64}' | base64 -d > "$desired_settings"
chmod 600 "$desired_settings"

if [ ! -e "$SETTINGS_FILE" ]; then
mv "$desired_settings" "$SETTINGS_FILE"
printf 'Created IDE settings: %s\n' "$SETTINGS_FILE"
exit 0
fi

if [ ! -f "$SETTINGS_FILE" ]; then
printf 'IDE settings path is not a regular file: %s\n' "$SETTINGS_FILE" >&2
exit 1
fi

if ! command -v python3 > /dev/null 2>&1; then
printf 'Merging an existing IDE settings file requires python3.\n' >&2
exit 1
fi

python3 - "$SETTINGS_FILE" "$desired_settings" "$merged_settings" << 'PY'
${file("${SCRIPTS_DIR}/merge-settings.py")}
PY

chmod 600 "$merged_settings"
mv "$merged_settings" "$SETTINGS_FILE"
printf 'Merged IDE settings: %s\n' "$SETTINGS_FILE"
119 changes: 119 additions & 0 deletions registry/coder/modules/vscode-desktop/scripts/merge-settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import json
import sys


def strip_jsonc_comments(source):
result = []
index = 0
in_string = False
escaped = False

while index < len(source):
character = source[index]

if in_string:
result.append(character)
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == '"':
in_string = False
index += 1
continue

if character == '"':
in_string = True
result.append(character)
index += 1
continue

if character == "/" and index + 1 < len(source):
next_character = source[index + 1]
if next_character == "/":
index += 2
while index < len(source) and source[index] not in "\r\n":
index += 1
continue
if next_character == "*":
result.append(" ")
index += 2
while index + 1 < len(source):
if source[index : index + 2] == "*/":
index += 2
break
if source[index] in "\r\n":
result.append(source[index])
index += 1
else:
raise ValueError("unterminated block comment in IDE settings")
continue

result.append(character)
index += 1

return "".join(result)


def strip_trailing_commas(source):
result = []
index = 0
in_string = False
escaped = False

while index < len(source):
character = source[index]

if in_string:
result.append(character)
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == '"':
in_string = False
index += 1
continue

if character == '"':
in_string = True
elif character == ",":
lookahead = index + 1
while lookahead < len(source) and source[lookahead].isspace():
lookahead += 1
if lookahead < len(source) and source[lookahead] in "}]":
index += 1
continue

result.append(character)
index += 1

return "".join(result)


def load_jsonc(path):
with open(path, encoding="utf-8-sig") as source_file:
source = source_file.read()
return json.loads(strip_trailing_commas(strip_jsonc_comments(source)))
Comment on lines +96 to +97

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A 0-byte settings.json currently raises JSONDecodeError and fails the startup script (verified), blocking login for 300s. Treating an empty file as an empty object is a cheap safeguard:

Suggested change
source = source_file.read()
return json.loads(strip_trailing_commas(strip_jsonc_comments(source)))
source = source_file.read()
if not source.strip():
return {}
return json.loads(strip_trailing_commas(strip_jsonc_comments(source)))



def merge(existing, configured):
result = dict(existing)
for key, value in configured.items():
if isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = merge(result[key], value)
else:
result[key] = value
return result


existing = load_jsonc(sys.argv[1])
with open(sys.argv[2], encoding="utf-8") as configured_file:
configured = json.load(configured_file)

if not isinstance(existing, dict):
raise ValueError("the existing IDE settings file must contain a JSON object")

with open(sys.argv[3], "w", encoding="utf-8") as merged_file:
json.dump(merge(existing, configured), merged_file, indent=2)
merged_file.write("\n")
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pathlib import Path
import sys


try:
print(Path(sys.argv[1]).resolve(strict=True))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A dangling symlink (target not created yet) makes resolve(strict=True) raise OSError, the script exits 1, and with start_blocks_login = true the user is blocked from logging in for the full 300s on every start. With strict=False the symlink resolves to its target path, the existing [ ! -e ] branch creates the file, and the symlink stays intact.

Suggested change
print(Path(sys.argv[1]).resolve(strict=True))
print(Path(sys.argv[1]).resolve(strict=False))

except (OSError, RuntimeError) as error:
print(f"Cannot resolve IDE settings symlink: {error}", file=sys.stderr)
raise SystemExit(1)
Loading