fix(zed): fix settings JSON parsing with base64 encoding (#604)

## Problem

The Zed module's settings parsing was broken. The previous
implementation used quote escaping:

```hcl
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
```

This produced invalid JSON like `{\"theme\":\"dark\"}` which **jq could
not parse** because the backslash-escaped quotes are not valid JSON
syntax.

## Solution

Changed to use base64 encoding internally:

```hcl
locals {
  settings_b64 = var.settings != "" ? base64encode(var.settings) : ""
}

# In the script:
SETTINGS_B64='${local.settings_b64}'
SETTINGS_JSON="$(echo -n "${SETTINGS_B64}" | base64 -d)"
```

**User interface remains the same** - users still provide plain JSON via
`jsonencode()` or heredoc:

```hcl
module "zed" {
  source   = "..."
  agent_id = coder_agent.main.id
  settings = jsonencode({
    theme    = "dark"
    fontSize = 14
  })
}
```

## Testing

Added comprehensive tests:

**Terraform tests (5):**
- URL generation (default, folder, agent_name)
- Settings base64 encoding verification
- Empty settings edge case

**Container e2e tests (3):**
- Creates settings file with correct JSON (including special chars:
quotes, backslashes, URLs)
- Merges with existing settings via jq
- Exits early with empty settings

Also fixed existing tests to use `override_data` for proper workspace
mocking.

---------

Signed-off-by: Muhammad Atif Ali <me@matifali.dev>
Co-authored-by: DevCats <christofer@coder.com>
This commit is contained in:
Atif Ali
2025-12-18 03:17:06 +05:00
committed by GitHub
parent 4b7128b17e
commit ef5a903edf
4 changed files with 184 additions and 52 deletions
+6 -5
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.3"
version = "1.1.4"
agent_id = coder_agent.main.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.3"
version = "1.1.4"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.3"
version = "1.1.4"
agent_id = coder_agent.main.id
display_name = "Zed Editor"
order = 1
@@ -57,7 +57,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.3"
version = "1.1.4"
agent_id = coder_agent.main.id
agent_name = coder_agent.example.name
}
@@ -73,7 +73,7 @@ You can declaratively set/merge settings with the `settings` input. Provide a JS
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.3"
version = "1.1.4"
agent_id = coder_agent.main.id
settings = jsonencode({
@@ -85,6 +85,7 @@ module "zed" {
env = {}
}
}
})
}
+98 -46
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -12,66 +16,114 @@ describe("zed", async () => {
agent_id: "foo",
});
it("default output", async () => {
it("creates settings file with correct JSON", async () => {
const settings = {
theme: "One Dark",
buffer_font_size: 14,
vim_mode: true,
telemetry: {
diagnostics: false,
metrics: false,
},
// Test special characters: single quotes, backslashes, URLs
message: "it's working",
path: "C:\\Users\\test",
api_url: "https://api.example.com/v1?token=abc&user=test",
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
settings: JSON.stringify(settings),
});
expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder");
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "zed",
);
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine:latest");
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
try {
const result = await execContainer(id, ["sh", "-c", instance.script]);
expect(result.exitCode).toBe(0);
const catResult = await execContainer(id, [
"cat",
"/root/.config/zed/settings.json",
]);
expect(catResult.exitCode).toBe(0);
const written = JSON.parse(catResult.stdout.trim());
expect(written).toEqual(settings);
} finally {
await removeContainer(id);
}
}, 30000);
it("merges settings with existing file when jq available", async () => {
const existingSettings = {
theme: "Solarized Dark",
vim_mode: true,
};
const newSettings = {
theme: "One Dark",
buffer_font_size: 14,
};
it("adds folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
settings: JSON.stringify(newSettings),
});
expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder/foo/bar");
});
it("expect order to be set", async () => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine:latest");
try {
// Install jq and create existing settings file
await execContainer(id, ["apk", "add", "--no-cache", "jq"]);
await execContainer(id, ["mkdir", "-p", "/root/.config/zed"]);
await execContainer(id, [
"sh",
"-c",
`echo '${JSON.stringify(existingSettings)}' > /root/.config/zed/settings.json`,
]);
const result = await execContainer(id, ["sh", "-c", instance.script]);
expect(result.exitCode).toBe(0);
const catResult = await execContainer(id, [
"cat",
"/root/.config/zed/settings.json",
]);
expect(catResult.exitCode).toBe(0);
const merged = JSON.parse(catResult.stdout.trim());
expect(merged.theme).toBe("One Dark"); // overwritten
expect(merged.buffer_font_size).toBe(14); // added
expect(merged.vim_mode).toBe(true); // preserved
} finally {
await removeContainer(id);
}
}, 30000);
it("exits early with empty settings", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
order: "22",
settings: "",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "zed",
);
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine:latest");
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
try {
const result = await execContainer(id, ["sh", "-c", instance.script]);
expect(result.exitCode).toBe(0);
it("expect display_name to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
display_name: "Custom Zed",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "zed",
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.display_name).toBe("Custom Zed");
});
it("adds agent_name to hostname", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
agent_name: "myagent",
});
expect(state.outputs.zed_url.value).toBe(
"zed://ssh/myagent.default.default.coder",
);
});
// Settings file should not be created
const catResult = await execContainer(id, [
"cat",
"/root/.config/zed/settings.json",
]);
expect(catResult.exitCode).not.toBe(0);
} finally {
await removeContainer(id);
}
}, 30000);
});
+6 -1
View File
@@ -65,6 +65,7 @@ locals {
owner_name = lower(data.coder_workspace_owner.me.name)
agent_name = lower(var.agent_name)
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
settings_b64 = var.settings != "" ? base64encode(var.settings) : ""
}
resource "coder_script" "zed_settings" {
@@ -75,7 +76,11 @@ resource "coder_script" "zed_settings" {
script = <<-EOT
#!/usr/bin/env bash
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
SETTINGS_B64='${local.settings_b64}'
if [ -z "$${SETTINGS_B64}" ]; then
exit 0
fi
SETTINGS_JSON="$(echo -n "$${SETTINGS_B64}" | base64 -d)"
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
exit 0
fi
+74
View File
@@ -5,6 +5,20 @@ run "default_output" {
agent_id = "foo"
}
override_data {
target = data.coder_workspace.me
values = {
name = "default"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
name = "default"
}
}
assert {
condition = output.zed_url == "zed://ssh/default.coder"
error_message = "zed_url did not match expected default URL"
@@ -19,6 +33,20 @@ run "adds_folder" {
folder = "/foo/bar"
}
override_data {
target = data.coder_workspace.me
values = {
name = "default"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
name = "default"
}
}
assert {
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
error_message = "zed_url did not include provided folder path"
@@ -33,8 +61,54 @@ run "adds_agent_name" {
agent_name = "myagent"
}
override_data {
target = data.coder_workspace.me
values = {
name = "default"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
name = "default"
}
}
assert {
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
error_message = "zed_url did not include agent_name in hostname"
}
}
run "settings_base64_encoding" {
command = apply
variables {
agent_id = "foo"
settings = jsonencode({
theme = "dark"
fontSize = 14
})
}
# Verify settings are base64 encoded (eyJ = base64 prefix for JSON starting with {")
assert {
condition = can(regex("SETTINGS_B64='eyJ", coder_script.zed_settings.script))
error_message = "settings should be base64 encoded in the script"
}
}
run "empty_settings" {
command = apply
variables {
agent_id = "foo"
settings = ""
}
assert {
condition = can(regex("SETTINGS_B64=''", coder_script.zed_settings.script))
error_message = "empty settings should result in empty SETTINGS_B64"
}
}