Compare commits

..

15 Commits

Author SHA1 Message Date
DevelopmentCats c5f6a00851 chore: bun fmt 2026-02-24 15:35:12 -06:00
DevelopmentCats 05e6324e41 fix: refactor extension installation in VS Code Web to use VSIX downloads 2026-02-24 15:19:49 -06:00
DevelopmentCats fd6f980610 fix: enhance extension installation process in VS Code Web by utilizing remote CLI 2026-02-24 14:56:13 -06:00
DevelopmentCats 3447d31392 fix: enhance VS Code Web server readiness check to use log output 2026-02-24 14:32:52 -06:00
DevelopmentCats 618a9b8b5d fix: improve VS Code Web server readiness check before installing extensions
- Added a function to wait for the VS Code Web server to be ready before proceeding with the installation of extensions.
- Replaced sleep commands with a conditional wait to ensure extensions are only installed after the server is confirmed to be running.
2026-02-24 14:26:22 -06:00
DevelopmentCats 847c9491af fix: adjust VS Code Web CLI execution order to ensure extensions are installed after server starts
- Added a sleep command to allow the VS Code Web server to start before installing extensions.
- Modified the script to run the VS Code Web CLI first, followed by the installation of extensions.
2026-02-24 14:02:29 -06:00
DevCats 10142cbe1c Merge branch 'main' into vscode-web-cli 2026-02-24 13:26:43 -06:00
DevelopmentCats 8ec817e33c refactor: update VS Code Web settings handling to merge with existing settings
- Changed test description to reflect merging behavior of settings.
- Updated Terraform variable description to clarify merging of settings.
- Implemented a new function in the run script to merge settings using jq or Python3.
- Adjusted the settings file creation logic to merge new settings with existing ones.
- Updated README to reflect changes in settings configuration and merging requirements.
2026-02-24 13:26:19 -06:00
Atif Ali 08bd84c529 Merge branch 'main' into vscode-web-cli 2026-02-10 10:25:08 +05:00
blink-so[bot] c493bbd490 fix: resolve SC2155 shellcheck warning in vscode-web run.sh
Separate declaration and assignment for local variable to avoid masking return values.
2026-01-12 15:47:22 +00:00
Atif Ali b55d546f03 Merge branch 'main' into vscode-web-cli 2026-01-09 17:59:39 +05:00
Atif Ali f1d5947245 Merge branch 'main' into vscode-web-cli 2026-01-08 00:23:11 +05:00
Muhammad Atif Ali e1eda2ce65 bunfmt 2026-01-05 12:20:27 +05:00
Atif Ali a1eed799aa Merge branch 'main' into vscode-web-cli 2026-01-05 12:18:55 +05:00
Muhammad Atif Ali b52c0f9f63 refactor(vscode-web): migrate to VS Code CLI with code serve-web
- Replace code-server with official VS Code CLI
- Download CLI from code.visualstudio.com using cli-alpine-* URLs
- Add release_channel variable (stable/insiders)
- Add commit_id variable to pin specific VS Code versions
- Support offline mode with fallback to code-server or cached vscode-server
- Add comprehensive bun tests for settings, extensions, and CLI arguments
- Add Terraform tests for variable validation
2025-12-31 16:28:08 +05:00
34 changed files with 1505 additions and 1227 deletions
+1 -11
View File
@@ -1,11 +1 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_147_2)">
<path d="M162.358 73H257V182H162.358V73Z" fill="white"/>
<path d="M0 182V78.4618H26.039L27.0034 103.381L24.3033 102.221C25.7177 96.6843 27.8391 91.9835 30.6684 88.1202C33.6255 84.2569 37.1618 81.2949 41.2769 79.2343C45.3914 77.1742 49.8921 76.1439 54.7785 76.1439C63.3938 76.1439 70.3377 78.6552 75.6097 83.6773C81.0105 88.6998 84.4824 95.4606 86.0251 103.96L82.3606 104.153C83.518 98.1008 85.5112 93.0138 88.3401 88.8931C91.2976 84.6431 94.8978 81.4883 99.1411 79.4277C103.385 77.2387 108.143 76.1439 113.415 76.1439C120.615 76.1439 126.788 77.6249 131.931 80.5869C137.075 83.5488 141.061 87.9913 143.89 93.9152C146.719 99.7102 148.133 106.858 148.133 115.357V182H119.201V123.47C119.201 115.357 117.98 109.305 115.536 105.312C113.093 101.191 109.107 99.1311 103.577 99.1311C100.106 99.1311 97.1484 100.097 94.7052 102.029C92.262 103.96 90.3332 106.793 88.9188 110.528C87.6326 114.134 86.9895 118.577 86.9895 123.857V182H60.9506V123.857C60.9506 115.872 59.7936 109.755 57.4787 105.505C55.1642 101.256 51.1779 99.1311 45.5202 99.1311C42.0482 99.1311 39.0263 100.097 36.4548 102.029C34.0117 103.96 32.1472 106.793 30.861 110.528C29.5753 114.262 28.9322 118.705 28.9322 123.857V182H0Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_147_2">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="62" fill="none" viewBox="0 0 135 62"><path fill="#fff" d="M3.168 48V22.272H9.648L9.888 28.464L9.216 28.176C9.568 26.8 10.096 25.632 10.8 24.672C11.536 23.712 12.416 22.976 13.44 22.464C14.464 21.952 15.584 21.696 16.8 21.696C18.944 21.696 20.672 22.32 21.984 23.568C23.328 24.816 24.192 26.496 24.576 28.608L23.664 28.656C23.952 27.152 24.448 25.888 25.152 24.864C25.888 23.808 26.784 23.024 27.84 22.512C28.896 21.968 30.08 21.696 31.392 21.696C33.184 21.696 34.72 22.064 36 22.8C37.28 23.536 38.272 24.64 38.976 26.112C39.68 27.552 40.032 29.328 40.032 31.44V48H32.832V33.456C32.832 31.44 32.528 29.936 31.92 28.944C31.312 27.92 30.32 27.408 28.944 27.408C28.08 27.408 27.344 27.648 26.736 28.128C26.128 28.608 25.648 29.312 25.296 30.24C24.976 31.136 24.816 32.24 24.816 33.552V48H18.336V33.552C18.336 31.568 18.048 30.048 17.472 28.992C16.896 27.936 15.904 27.408 14.496 27.408C13.632 27.408 12.88 27.648 12.24 28.128C11.632 28.608 11.168 29.312 10.848 30.24C10.528 31.168 10.368 32.272 10.368 33.552V48H3.168ZM54.2254 48.576C51.5694 48.576 49.4894 47.728 47.9854 46.032C46.5134 44.304 45.7774 41.904 45.7774 38.832V22.272H52.9774V37.152C52.9774 39.136 53.2814 40.592 53.8894 41.52C54.4974 42.416 55.4574 42.864 56.7694 42.864C58.2414 42.864 59.3774 42.368 60.1774 41.376C61.0094 40.352 61.4254 38.832 61.4254 36.816V22.272H68.6254V48H62.0494L61.8574 40.608L62.7694 40.8C62.3854 43.36 61.4734 45.296 60.0334 46.608C58.5934 47.92 56.6574 48.576 54.2254 48.576ZM72.8486 48L82.0166 34.944L73.0886 22.272H80.7206L86.2406 30.528L91.5686 22.272H99.3926L90.5126 34.992L99.6326 48H92.0006L86.3366 39.264L80.6246 48H72.8486Z"/><rect width="26" height="35" x="109" y="13" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+5 -5
View File
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -94,7 +94,7 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
@@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
+10 -1
View File
@@ -464,13 +464,22 @@ describe("codex", async () => {
});
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
expect(configToml).toContain('profile = "aibridge"');
});
});
+8 -11
View File
@@ -136,8 +136,8 @@ variable "agentapi_version" {
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.3-codex"
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "gpt-5.2-codex"
}
variable "pre_install_script" {
@@ -184,13 +184,12 @@ resource "coder_env" "coder_aibridge_session_token" {
}
locals {
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.3-codex"
aibridge_config = <<-EOF
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
@@ -250,8 +249,6 @@ module "agentapi" {
chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
@@ -20,8 +20,6 @@ echo "=== Codex Module Configuration ==="
printf "Install Codex: %s\n" "$ARG_INSTALL"
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
@@ -92,25 +90,15 @@ function install_codex() {
write_minimal_default_config() {
local config_path="$1"
ARG_DEFAULT_PROFILE=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_DEFAULT_PROFILE='profile = "aibridge"'
fi
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_DEFAULT_PROFILE}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
EOF
}
@@ -155,8 +155,11 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
@@ -210,7 +213,7 @@ capture_session_id() {
start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
capture_session_id
}
+1 -28
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
version = "2.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -62,33 +62,6 @@ module "agentapi" {
}
```
## State Persistence
AgentAPI can save and restore conversation state across workspace restarts.
This is disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
```tf
module "agentapi" {
# ... other config
enable_state_persistence = true
}
```
To override file paths:
```tf
module "agentapi" {
# ... other config
state_file_path = "/custom/path/state.json"
pid_file_path = "/custom/path/agentapi.pid"
}
```
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -1,108 +0,0 @@
mock_provider "coder" {}
variables {
agent_id = "test-agent"
web_app_icon = "/icon/test.svg"
web_app_display_name = "Test"
web_app_slug = "test"
cli_app_display_name = "Test CLI"
cli_app_slug = "test-cli"
start_script = "echo test"
module_dir_name = ".test-module"
}
run "default_values" {
command = plan
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should default to false"
}
assert {
condition = var.state_file_path == ""
error_message = "state_file_path should default to empty string"
}
assert {
condition = var.pid_file_path == ""
error_message = "pid_file_path should default to empty string"
}
# Verify start script contains state persistence ARG_ vars.
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
assert {
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_STATE_FILE_PATH"
}
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_PID_FILE_PATH"
}
# Verify shutdown script contains PID-related ARG_ vars.
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
}
assert {
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
}
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
}
run "state_persistence_disabled" {
command = plan
variables {
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false"
}
# Even when disabled, the ARG_ vars should still be in the script
# (the shell script handles the conditional logic).
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
}
}
run "custom_paths" {
command = plan
variables {
state_file_path = "/custom/state.json"
pid_file_path = "/custom/agentapi.pid"
}
assert {
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
error_message = "start script should contain custom state_file_path"
}
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
error_message = "start script should contain custom pid_file_path"
}
# Verify custom paths also appear in shutdown script.
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain custom pid_file_path"
}
}
+2 -205
View File
@@ -258,76 +258,11 @@ describe("agentapi", async () => {
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
test("state-persistence-disabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "false",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
// PID file should always be exported
expect(mockLog).toContain("AGENTAPI_PID_FILE:");
// State vars should NOT be present when disabled
expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:");
expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:");
expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:");
});
test("state-persistence-custom-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
state_file_path: "/home/coder/custom/state.json",
pid_file_path: "/home/coder/custom/agentapi.pid",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
"AGENTAPI_STATE_FILE: /home/coder/custom/state.json",
);
expect(mockLog).toContain(
"AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid",
);
});
test("state-persistence-default-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
);
expect(mockLog).toContain(
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
);
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
});
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
pidFilePath: string = "",
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
@@ -350,11 +285,10 @@ describe("agentapi", async () => {
content: coderMock,
});
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
@@ -369,25 +303,12 @@ describe("agentapi", async () => {
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
pidFilePath: string = "",
enableStatePersistence: string = "false",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
@@ -397,7 +318,7 @@ describe("agentapi", async () => {
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
@@ -413,7 +334,6 @@ describe("agentapi", async () => {
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
expect(result.stdout).not.toContain("Log snapshot capture failed");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
@@ -489,128 +409,5 @@ describe("agentapi", async () => {
"Log snapshot endpoint not supported by this Coder version",
);
});
test("sends SIGUSR1 before shutdown", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
expect(sigusr1Log).toContain("SIGUSR1 received");
});
test("handles missing PID file gracefully", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
// Pass a non-existent PID file path with persistence enabled to
// exercise the SIGUSR1 path with a missing PID.
const result = await runShutdownScript(
id,
"test-task",
"/tmp/nonexistent.pid",
"true",
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Shutdown complete");
});
test("sends SIGTERM even when snapshot fails", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
// HTTP 500 will cause snapshot to fail
await setupMocks(id, "normal", 500, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot capture failed, continuing shutdown",
);
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("resolves default PID path from MODULE_DIR_NAME", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
// Start mock with PID file at the module_dir_name default location.
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
await setupMocks(id, "normal", 204, defaultPidPath);
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId: id,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId: id,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
const result = await execContainer(id, [
"bash",
"-c",
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("skips SIGUSR1 when no PID file available", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 204);
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
const result = await runShutdownScript(id, "test-task", "", "false");
expect(result.exitCode).toBe(0);
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
expect(result.stdout).not.toContain("Sending SIGUSR1");
expect(result.stdout).not.toContain("Sending SIGTERM");
expect(result.stdout).toContain("Shutdown complete");
});
test("skips SIGUSR1 when state persistence disabled", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
// PID file exists but state persistence is disabled.
const result = await runShutdownScript(id, "test-task", pidFile, "false");
expect(result.exitCode).toBe(0);
// Should NOT send SIGUSR1 (persistence disabled).
expect(result.stdout).not.toContain("Sending SIGUSR1");
// Should still send SIGTERM (graceful shutdown always happens).
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
});
});
-26
View File
@@ -164,23 +164,6 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files."
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = false
}
variable "state_file_path" {
type = string
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
default = ""
}
variable "pid_file_path" {
type = string
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/agentapi.pid."
default = ""
}
locals {
# we always trim the slash for consistency
@@ -199,7 +182,6 @@ locals {
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
}
resource "coder_script" "agentapi" {
@@ -213,7 +195,6 @@ resource "coder_script" "agentapi" {
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /tmp/main.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
@@ -228,9 +209,6 @@ resource "coder_script" "agentapi" {
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_STATE_FILE_PATH='${var.state_file_path}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/main.sh
EOT
run_on_start = true
@@ -247,14 +225,10 @@ resource "coder_script" "agentapi_shutdown" {
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/agentapi-shutdown.sh
EOT
}
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save,
# captures the last 10 messages as a log snapshot posted to the Coder instance,
# then sends SIGTERM for graceful termination.
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
@@ -11,13 +11,6 @@ set -euo pipefail
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
# Source shared utilities (written by the coder_script wrapper).
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
@@ -27,7 +20,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
@@ -145,45 +138,44 @@ post_task_log_snapshot() {
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
return 0
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
return 1
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
return 1
exit 1
fi
# Not local, must be visible to the EXIT trap after the function returns.
tmpdir=$(mktemp -d)
trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
return 1
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
return 0
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
@@ -191,7 +183,7 @@ capture_task_log_snapshot() {
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
return 1
exit 1
fi
local final_size final_count
@@ -201,60 +193,19 @@ capture_task_log_snapshot() {
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
return 1
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
local agentapi_pid=
if [[ -n $PID_FILE_PATH ]]; then
agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "")
fi
# State persistence is only enabled when the binary supports it (>= v0.12.0).
# The default SIGUSR1 disposition on Linux is terminate, so sending it to an
# older binary would kill the process.
local state_persistence=0
if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then
state_persistence=1
fi
# Trigger state save via SIGUSR1 (saves without exiting).
if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state"
kill -USR1 "$agentapi_pid" || true
# Allow time for state save to complete before proceeding.
sleep 1
fi
# Capture log snapshot for task history.
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
# Subshell scopes the EXIT trap (tmpdir cleanup) inside
# capture_task_log_snapshot and preserves set -e, which
# || would otherwise disable for the function body.
(capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown"
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
# Graceful termination.
if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)"
kill -TERM "$agentapi_pid" 2> /dev/null || true
# Wait for process to exit to guarantee a clean shutdown.
local elapsed=0
while kill -0 "$agentapi_pid" 2> /dev/null; do
sleep 1
((elapsed++)) || true
if ((elapsed % 5 == 0)); then
log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s"
fi
done
fi
log "Shutdown complete"
}
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Shared utility functions for agentapi module scripts.
# version_at_least checks if an actual version meets a minimum requirement.
# Non-semver strings (e.g. "latest", custom builds) always pass.
# Usage: version_at_least <minimum> <actual>
# version_at_least v0.12.0 v0.10.0 # returns 1 (false)
# version_at_least v0.12.0 v0.12.0 # returns 0 (true)
# version_at_least v0.12.0 latest # returns 0 (true)
version_at_least() {
local min="${1#v}"
local actual="${2#v}"
# Non-semver versions pass through (e.g. "latest", custom builds).
if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
return 0
fi
local act_major="${BASH_REMATCH[1]}"
local act_minor="${BASH_REMATCH[2]}"
local act_patch="${BASH_REMATCH[3]}"
[[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0
local min_major="${BASH_REMATCH[1]}"
local min_minor="${BASH_REMATCH[2]}"
local min_patch="${BASH_REMATCH[3]}"
# Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero.
if ((act_major != min_major)); then
((act_major > min_major))
return
fi
if ((act_minor != min_minor)); then
((act_minor > min_minor))
return
fi
((act_patch >= min_patch))
}
# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8").
# Returns empty string if the binary is missing or doesn't support --version.
agentapi_version() {
agentapi --version 2> /dev/null | awk '{print $NF}'
}
@@ -16,14 +16,8 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
set +o nounset
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
command_exists() {
command -v "$1" > /dev/null 2>&1
}
@@ -112,18 +106,5 @@ cd "${WORKDIR}"
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
actual_version=$(agentapi_version)
if version_at_least 0.12.0 "$actual_version"; then
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
export AGENTAPI_SAVE_STATE="true"
export AGENTAPI_LOAD_STATE="true"
else
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
fi
fi
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
@@ -3,26 +3,8 @@
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 3284;
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
// Handle SIGUSR1 (state save signal from shutdown script).
process.on("SIGUSR1", () => {
fs.writeFileSync(
"/tmp/sigusr1-received",
`SIGUSR1 received at ${Date.now()}\n`,
);
});
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
@@ -6,41 +6,12 @@ const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284;
if (args.includes("--version")) {
console.log("agentapi version 99.99.99");
process.exit(0);
}
console.log(`starting server on port ${port}`);
fs.writeFileSync(
"/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
);
// Log state persistence env vars.
for (const v of [
"AGENTAPI_STATE_FILE",
"AGENTAPI_PID_FILE",
"AGENTAPI_SAVE_STATE",
"AGENTAPI_LOAD_STATE",
]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
http
.createServer(function (_request, response) {
response.writeHead(200);
+9 -22
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -36,19 +36,6 @@ module "claude-code" {
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
## Examples
### Usage with Agent Boundaries
@@ -60,7 +47,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -81,7 +68,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -110,7 +97,7 @@ data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
@@ -133,7 +120,7 @@ This example shows additional configuration options for version pinning, custom
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -189,7 +176,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -211,7 +198,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -284,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -341,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+18 -25
View File
@@ -261,12 +261,6 @@ variable "enable_aibridge" {
}
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
@@ -362,26 +356,25 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -387,36 +387,6 @@ run "test_aibridge_disabled_with_api_key" {
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
+8 -8
View File
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
install_version = "4.106.3"
}
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,7 +78,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust"
}
@@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.3"
version = "1.4.2"
agent_id = coder_agent.example.id
offline = true
}
+2 -2
View File
@@ -44,7 +44,7 @@ variable "settings" {
default = {}
}
variable "machine_settings" {
variable "machine-settings" {
type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {}
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
+6 -20
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,34 +54,20 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
}
```
## SSH vs HTTPS URLs
If your Git provider (e.g. GitLab, GitHub Enterprise) restricts HTTPS cloning, use an SSH URL instead:
```text
# HTTPS (may fail if HTTP cloning is disabled)
https://gitlab.example.com/user/dotfiles.git
# SSH (uses the workspace's SSH key)
git@gitlab.example.com:user/dotfiles.git
```
When a Git provider has HTTPS cloning disabled server-side, the clone will silently fail (the `.git` folder may exist but the working tree will be empty). SSH URLs avoid this because they authenticate with the workspace's SSH key instead of a token-based HTTPS flow.
## Setting a default dotfiles repository
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
@@ -90,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.3.0"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
@@ -26,7 +26,6 @@ describe("dotfiles", async () => {
"git@github.com:coder/dotfiles.git",
"git://github.com/coder/dotfiles.git",
"ssh://git@github.com/coder/dotfiles.git",
"ssh://git@bitbucket.example.org:7999/~myusername/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
+4 -4
View File
@@ -29,7 +29,7 @@ variable "agent_id" {
variable "description" {
type = string
description = "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
}
variable "default_dotfiles_uri" {
@@ -40,7 +40,7 @@ variable "default_dotfiles_uri" {
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.default_dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
@@ -55,7 +55,7 @@ variable "dotfiles_uri" {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
@@ -102,7 +102,7 @@ data "coder_parameter" "dotfiles_uri" {
icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$"
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
+10 -55
View File
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,24 +63,9 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
```
### Pass Arbitrary `mux server` Arguments
Use `additional_arguments` to append additional arguments to `mux server`.
The module parses quoted values, so grouped arguments remain intact.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
add-project = "/path/to/project"
}
```
@@ -90,40 +75,12 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
port = 8080
}
```
### Custom Package Manager
Force a specific package manager instead of auto-detection:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
```
### Custom Registry
Use a private or mirrored npm registry:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
```
### Use Cached Installation
Run an existing copy of Mux if found, otherwise install from npm:
@@ -132,7 +89,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -146,7 +103,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.1.0"
agent_id = coder_agent.main.id
install = false
}
@@ -160,6 +117,4 @@ module "mux" {
- Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false)
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Falls back to a direct tarball download when no package manager is found
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
+2 -58
View File
@@ -1,11 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -35,7 +30,7 @@ describe("mux", async () => {
}
expect(output.exitCode).toBe(0);
const expectedLines = [
"📥 No package manager found; downloading tarball from registry...",
"📥 npm not found; downloading tarball from npm registry...",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
@@ -45,57 +40,6 @@ describe("mux", async () => {
}
}, 60000);
it("parses custom additional_arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
expect(log).toContain("arg4=--open-mode");
expect(log).toContain("arg5=pinned");
expect(log).toContain("arg6=--add-project");
expect(log).toContain("arg7=/workspaces/my repo");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
@@ -111,7 +55,7 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);
const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...",
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
+2 -29
View File
@@ -49,41 +49,18 @@ variable "log_path" {
default = "/tmp/mux.log"
}
variable "add_project" {
variable "add-project" {
type = string
description = "Optional path to add/open as a project in Mux on startup."
default = null
}
variable "additional_arguments" {
type = string
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
default = ""
}
variable "install_version" {
type = string
description = "The version or dist-tag of Mux to install."
default = "next"
}
variable "package_manager" {
type = string
description = "Package manager to install Mux. 'auto' detects npm, pnpm, or bun (falling back to tarball download). Set to 'npm', 'pnpm', or 'bun' to force a specific one."
default = "auto"
validation {
condition = contains(["auto", "npm", "pnpm", "bun"], var.package_manager)
error_message = "The 'package_manager' variable must be one of: 'auto', 'npm', 'pnpm', 'bun'."
}
}
variable "registry_url" {
type = string
description = "The npm-compatible registry URL to install Mux from. Override this for private registries or mirrors."
default = "https://registry.npmjs.org"
}
variable "share" {
type = string
default = "owner"
@@ -154,7 +131,6 @@ resource "random_password" "mux_auth_token" {
locals {
mux_auth_token = random_password.mux_auth_token.result
registry_url = trimsuffix(var.registry_url, "/")
}
resource "coder_script" "mux" {
@@ -165,14 +141,11 @@ resource "coder_script" "mux" {
VERSION : var.install_version,
PORT : var.port,
LOG_PATH : var.log_path,
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
ADDITIONAL_ARGUMENTS : var.additional_arguments,
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
run_on_start = true
-107
View File
@@ -79,20 +79,6 @@ run "auth_token_in_url" {
}
}
run "custom_additional_arguments" {
command = plan
variables {
agent_id = "foo"
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
error_message = "mux launch script must include the configured additional arguments"
}
}
run "custom_version" {
command = plan
@@ -121,96 +107,3 @@ run "use_cached_only_success" {
use_cached = true
}
}
# Custom package_manager should appear in generated script
run "custom_package_manager_npm" {
command = plan
variables {
agent_id = "foo"
package_manager = "npm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_pnpm" {
command = plan
variables {
agent_id = "foo"
package_manager = "pnpm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_bun" {
command = plan
variables {
agent_id = "foo"
package_manager = "bun"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
# Invalid package_manager should fail validation
run "invalid_package_manager" {
command = plan
variables {
agent_id = "foo"
package_manager = "yarn"
}
expect_failures = [
var.package_manager
]
}
# Custom registry_url should appear in generated script
run "custom_registry_url" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com")
error_message = "mux script must use the configured registry URL"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org")
error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set"
}
}
# registry_url trailing slash should be stripped
run "registry_url_trailing_slash" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com/"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/")
error_message = "registry URL trailing slash must be stripped to avoid double slashes"
}
}
+10 -61
View File
@@ -20,22 +20,6 @@ function run_mux() {
if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}"
fi
# Parse additional user-supplied server arguments while preserving quoted groups.
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
local parsed_additional_arguments
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
exit 1
fi
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF
$${parsed_additional_arguments}
EOF
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
@@ -54,7 +38,7 @@ fi
# If there is no cached install OR we don't want to use a cached install
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
printf "$${BOLD}Installing mux...\n"
printf "$${BOLD}Installing mux from npm...\n"
# Clean up from other install (in case install prefix changed).
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
@@ -63,76 +47,41 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
mkdir -p "$(dirname "$MUX_BINARY")"
# Determine which package manager to use
PM_CMD=""
if [ "${PACKAGE_MANAGER}" = "auto" ]; then
for pm in npm pnpm bun; do
if command -v "$pm" > /dev/null 2>&1; then
PM_CMD="$pm"
break
fi
done
else
PM_CMD="${PACKAGE_MANAGER}"
if ! command -v "$PM_CMD" > /dev/null 2>&1; then
echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH"
exit 1
fi
fi
if [ -n "$PM_CMD" ]; then
echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..."
if command -v npm > /dev/null 2>&1; then
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
NPM_WORKDIR="${INSTALL_PREFIX}/npm"
mkdir -p "$NPM_WORKDIR"
cd "$NPM_WORKDIR" || exit 1
if [ ! -f package.json ]; then
echo '{}' > package.json
fi
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts"
PKG="mux"
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
PKG_SPEC="$PKG@latest"
else
PKG_SPEC="$PKG@${VERSION}"
fi
INSTALL_OK=true
case "$PM_CMD" in
npm)
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
pnpm)
if ! pnpm add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
bun)
if ! bun add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
esac
if [ "$INSTALL_OK" != true ]; then
echo "❌ Failed to install mux via $PM_CMD"
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
echo "❌ Failed to install mux via npm"
exit 1
fi
# Determine the installed binary path
BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
CANDIDATE="$BIN_DIR/mux"
if [ ! -f "$CANDIDATE" ]; then
echo "❌ Could not locate mux binary after $PM_CMD install"
echo "❌ Could not locate mux binary after npm install"
exit 1
fi
chmod +x "$CANDIDATE" || true
ln -sf "$CANDIDATE" "$MUX_BINARY"
else
echo "📥 No package manager found; downloading tarball from registry..."
echo "📥 npm not found; downloading tarball from npm registry..."
VERSION_TO_USE="${VERSION}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE="next"
fi
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
META_JSON="$(curl -fsSL "$META_URL" || true)"
if [ -z "$META_JSON" ]; then
echo "❌ Failed to fetch npm metadata: $META_URL"
@@ -171,7 +120,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
echo "❌ Could not determine version for mux"
exit 1
fi
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
fi
TMP_DIR="$(mktemp -d)"
TAR_PATH="$TMP_DIR/mux.tgz"
+46 -26
View File
@@ -8,13 +8,13 @@ tags: [ide, vscode, web]
# VS Code Web
Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard.
Automatically install the [VS Code CLI](https://code.visualstudio.com/docs/editor/command-line) and run `code serve-web` in a workspace to access VS Code via the browser.
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -30,7 +30,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -44,22 +44,22 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
}
```
### Pre-configure Settings
### Pre-configure Machine Settings
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -69,20 +69,7 @@ module "vscode-web" {
}
```
### Pin a specific VS Code Web version
By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
> **Note:** Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
### Open an existing workspace on startup
@@ -91,10 +78,43 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
accept_license = true
}
```
### Use VS Code Insiders
Use the VS Code Insiders release channel to get the latest features and bug fixes:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
release_channel = "insiders"
accept_license = true
}
```
### Pin a specific VS Code version
Use the `commit_id` variable to pin a specific VS Code Server version by its commit SHA:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
You can find the commit SHA for a specific VS Code version on the [VS Code releases page](https://code.visualstudio.com/updates) or by checking the "About" dialog in VS Code.
+782 -31
View File
@@ -1,42 +1,793 @@
import { describe, expect, it } from "bun:test";
import { runTerraformApply, runTerraformInit } from "~test";
import {
describe,
expect,
it,
beforeAll,
afterEach,
setDefaultTimeout,
} from "bun:test";
import {
runTerraformApply,
runTerraformInit,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
} from "~test";
// Set timeout to 5 minutes for tests that download VS Code CLI
setDefaultTimeout(5 * 60 * 1000);
let cleanupContainers: string[] = [];
afterEach(async () => {
for (const id of cleanupContainers) {
try {
await removeContainer(id);
} catch {
// Ignore cleanup errors
}
}
cleanupContainers = [];
});
describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir);
it("accept_license should be set to true", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "false",
});
};
expect(t).toThrow("Invalid value for variable");
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
it("use_cached and offline can not be used together", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
use_cached: "true",
offline: "true",
});
};
expect(t).toThrow("Offline and Use Cached can not be used together");
describe("terraform validation", () => {
it("accept_license should be set to true", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: false,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
});
it("use_cached and offline can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
offline: true,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline and Use Cached can not be used together",
);
}
});
it("offline and extensions can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
extensions: '["ms-python.python"]',
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline mode does not allow extensions to be installed",
);
}
});
it("workspace and folder can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
folder: "/home/coder",
workspace: "/home/coder/test.code-workspace",
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Set only one of `workspace` or `folder`",
);
}
});
});
it("offline and extensions can not be used together", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
describe("script generation", () => {
it("generates script with correct port", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
offline: "true",
extensions: '["1", "2"]',
accept_license: true,
port: 8080,
});
};
expect(t).toThrow("Offline mode does not allow extensions to be installed");
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--port 8080");
});
it("generates script with extensions directory", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions_dir: "/custom/extensions",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--extensions-dir=/custom/extensions");
});
it("generates script with telemetry level", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
telemetry_level: "off",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--telemetry-level off");
});
it("generates script with disable trust", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
disable_trust: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--disable-workspace-trust");
});
it("generates script with serve-web command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("serve-web");
expect(script.script).toContain("--accept-server-license-terms");
expect(script.script).toContain("--without-connection-token");
});
it("generates script with stable release channel by default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=stable");
});
it("generates script with insiders release channel", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
release_channel: "insiders",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=insiders");
});
it("generates script without commit-id value when not specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
// The if condition should have an empty string, so no commit-id value is passed
expect(script.script).toContain('if [ -n "" ]; then');
// Should not contain any actual commit hash
expect(script.script).not.toMatch(/--commit-id [a-f0-9]{40}/);
});
it("generates script with commit-id when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "e54c774e0add60467559eb0d1e229c6452cf8447",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain(
"--commit-id e54c774e0add60467559eb0d1e229c6452cf8447",
);
});
});
// More tests depend on shebang refactors
describe("container integration tests", () => {
it("uses existing code CLI in PATH", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs when serve-web is called
await execContainer(containerId, [
"bash",
"-c",
`cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_SERVER_STARTED with args: \$@"
exit 0
fi
echo "code mock called: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - the mock will capture the serve-web call
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Found VS Code CLI");
});
it("offline mode fails when CLI not present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain(
"Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found",
);
});
it("offline mode uses code-server as fallback", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install mock code-server in PATH
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_SERVER_STARTED with args: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code-server`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("offline fallback");
expect(result.stdout).toContain("Starting code-server");
});
it("offline mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI at expected location
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_OFFLINE_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
expect(result.stdout).toContain("Starting VS Code Web");
});
it("use_cached mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_CACHED_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
});
it("creates settings file with correct content", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"editor.fontSize": 14}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings file was created
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("14");
});
it("creates settings file with multiple settings", async () => {
const settings = {
"editor.fontSize": 16,
"editor.tabSize": 2,
"workbench.colorTheme": "Dracula",
"editor.formatOnSave": true,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings file was created with all settings
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("16");
expect(settingsResult.stdout).toContain("editor.tabSize");
expect(settingsResult.stdout).toContain("2");
expect(settingsResult.stdout).toContain("workbench.colorTheme");
expect(settingsResult.stdout).toContain("Dracula");
expect(settingsResult.stdout).toContain("editor.formatOnSave");
expect(settingsResult.stdout).toContain("true");
});
it("creates settings file in correct directory structure", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"test.setting": "value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Verify directory structure was created
const dirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/Machine/",
]);
expect(dirResult.exitCode).toBe(0);
expect(dirResult.stdout).toContain("settings.json");
// Verify parent directories exist
const parentDirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/",
]);
expect(parentDirResult.exitCode).toBe(0);
expect(parentDirResult.stdout).toContain("Machine");
});
it("merges settings with existing settings file", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"new.setting": "new_value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"jq",
]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings were merged (both existing and new should be present)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
it("creates valid JSON settings file", async () => {
const settings = {
"editor.fontSize": 14,
"editor.wordWrap": "on",
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"jq",
]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Validate JSON using jq
const jsonValidResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.' /root/.vscode-server/data/Machine/settings.json",
]);
expect(jsonValidResult.exitCode).toBe(0);
// Extract specific values using jq
const fontSizeResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.fontSize\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(fontSizeResult.stdout.trim()).toBe("14");
const wordWrapResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.wordWrap\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(wordWrapResult.stdout.trim()).toBe('"on"');
const autoSaveDelayResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"files.autoSaveDelay\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(autoSaveDelayResult.stdout.trim()).toBe("1000");
});
it("installs extensions", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions: '["ms-python.python", "golang.go"]',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs extension installs
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "--install-extension" ]; then
echo "MOCK_EXTENSION_INSTALL: \$2"
fi
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Installing extension");
expect(result.stdout).toContain("ms-python.python");
expect(result.stdout).toContain("golang.go");
});
it("runs with correct server arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 9999,
telemetry_level: "off",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that captures all arguments
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
// Check the output contains expected port message
expect(result.stdout).toContain("Starting VS Code Web on port 9999");
});
it("passes commit-id to code CLI when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "abc123def456",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs arguments to the log file (where output is redirected)
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Wait briefly for background process to write to log
await new Promise((resolve) => setTimeout(resolve, 500));
// Check the log file for the arguments (code CLI output goes there)
const logResult = await execContainer(containerId, [
"cat",
"/tmp/vscode-web.log",
]);
expect(logResult.exitCode).toBe(0);
expect(logResult.stdout).toContain("--commit-id abc123def456");
});
// This test downloads and starts the real VS Code server
it("starts real VS Code CLI and responds to healthcheck (requires network)", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 13338,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install curl for downloading CLI and healthcheck
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"curl",
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - it will start the server in background
const startResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(startResult.exitCode).toBe(0);
expect(startResult.stdout).toContain("Starting VS Code Web");
// Wait for server to start and check healthcheck
await new Promise((resolve) => setTimeout(resolve, 10000));
const healthResult = await execContainer(containerId, [
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"http://127.0.0.1:13338/healthz",
]);
// Server should respond (200, 202, or 404 is acceptable - means server is running)
expect(["200", "202", "404"]).toContain(healthResult.stdout.trim());
});
});
});
+23 -20
View File
@@ -59,12 +59,6 @@ variable "install_prefix" {
default = "/tmp/vscode-web"
}
variable "commit_id" {
type = string
description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used."
default = ""
}
variable "extensions" {
type = list(string)
description = "A list of extensions to install."
@@ -105,7 +99,7 @@ variable "group" {
variable "settings" {
type = any
description = "A map of settings to apply to VS Code web."
description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup."
default = {}
}
@@ -148,25 +142,35 @@ variable "subdomain" {
default = true
}
variable "platform" {
type = string
description = "The platform to use for the VS Code Web."
default = ""
validation {
condition = var.platform == "" || var.platform == "linux" || var.platform == "darwin" || var.platform == "alpine" || var.platform == "win32"
error_message = "Incorrect value. Please set either 'linux', 'darwin', or 'alpine' or 'win32'."
}
}
variable "workspace" {
type = string
description = "Path to a .code-workspace file to open in vscode-web."
default = ""
}
variable "release_channel" {
type = string
description = "The release channel for VS Code CLI (stable or insiders)."
default = "stable"
validation {
condition = var.release_channel == "stable" || var.release_channel == "insiders"
error_message = "Incorrect value. Please set either 'stable' or 'insiders'."
}
}
variable "commit_id" {
type = string
description = "The commit SHA to use for the VS Code Server. Leave empty to use the latest version."
default = ""
}
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
locals {
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
}
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" {
INSTALL_PREFIX : var.install_prefix,
EXTENSIONS : join(",", var.extensions),
TELEMETRY_LEVEL : var.telemetry_level,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
SETTINGS_B64 : local.settings_b64,
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
DISABLE_TRUST : var.disable_trust,
@@ -187,8 +190,8 @@ resource "coder_script" "vscode-web" {
WORKSPACE : var.workspace,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
RELEASE_CHANNEL : var.release_channel,
COMMIT_ID : var.commit_id,
PLATFORM : var.platform,
})
run_on_start = true
+384 -105
View File
@@ -1,138 +1,417 @@
#!/usr/bin/env bash
BOLD='\033[0;1m'
RESET='\033[0m'
CODE='\033[36;40;1m'
EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
# Set extension directory
# Merge settings from module with existing settings file
# Uses jq if available, falls back to Python3 for deep merge
merge_settings() {
local new_settings="$1"
local settings_file="$2"
if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then
return 0
fi
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
printf '%s\n' "$new_settings" > "$settings_file"
printf "Creating settings file...\n"
return 0
fi
local tmpfile
tmpfile="$(mktemp)"
if command -v jq > /dev/null 2>&1; then
if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "Merging settings...\n"
return 0
fi
fi
if command -v python3 > /dev/null 2>&1; then
if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "Merging settings...\n"
return 0
fi
fi
rm -f "$tmpfile"
printf "Warning: Could not merge settings. Keeping existing settings.\n"
return 0
}
# Set extension directory argument
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set extension directory
# Set server base path argument
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
fi
# Set disable workspace trust
# Set disable workspace trust argument
DISABLE_TRUST_ARG=""
if [ "${DISABLE_TRUST}" = true ]; then
DISABLE_TRUST_ARG="--disable-workspace-trust"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
# Check if code CLI is installed
check_code_cli() {
if command -v code > /dev/null 2>&1; then
echo "code"
return 0
fi
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
echo "${INSTALL_PREFIX}/bin/code"
return 0
fi
return 1
}
# Check if the settings file exists...
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
echo "⚙️ Creating settings file..."
mkdir -p ~/.vscode-server/data/Machine
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
fi
# Check if vscode-server is already installed for offline or cached mode
if [ -f "$VSCODE_WEB" ]; then
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
echo "🥳 Found a copy of VS Code Web"
run_vscode_web
exit 0
# Check if code-server is installed (fallback option)
check_code_server() {
if command -v code-server > /dev/null 2>&1; then
echo "code-server"
return 0
fi
fi
# Offline mode always expects a copy of vscode-server to be present
if [ "${OFFLINE}" = true ]; then
echo "Failed to find a copy of VS Code Web"
exit 1
fi
# Create install prefix
mkdir -p ${INSTALL_PREFIX}
printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
# Download and extract vscode-server
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*)
echo "Unsupported architecture"
exit 1
;;
esac
# Detect the platform
if [ -n "${PLATFORM}" ]; then
DETECTED_PLATFORM="${PLATFORM}"
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then
DETECTED_PLATFORM="alpine"
elif [ "$(uname -s)" = "Darwin" ]; then
DETECTED_PLATFORM="darwin"
else
DETECTED_PLATFORM="linux"
fi
# Check if a specific VS Code Web commit ID was provided
if [ -n "${COMMIT_ID}" ]; then
HASH="${COMMIT_ID}"
else
HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-$DETECTED_PLATFORM-$ARCH-web | cut -d '"' -f 2)
fi
printf "$${BOLD}VS Code Web commit id version $HASH.\n"
output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$DETECTED_PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1)
if [ $? -ne 0 ]; then
echo "Failed to install Microsoft Visual Studio Code Server: $output"
exit 1
fi
printf "$${BOLD}VS Code Web has been installed.\n"
# Install each extension...
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
# shellcheck disable=SC2066
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
if [ -f "${INSTALL_PREFIX}/bin/code-server" ]; then
echo "${INSTALL_PREFIX}/bin/code-server"
return 0
fi
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
fi
done
return 1
}
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
# Prefer WORKSPACE if set and points to a file
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}"
# Strip single-line comments then parse .extensions.recommendations[]
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
else
# Fallback to folder-based .vscode/extensions.json (existing behavior)
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
# Find existing vscode-server binary (used by code serve-web internally)
find_vscode_server() {
# Check common locations for pre-downloaded vscode-server
local server_dirs=(
"$HOME/.vscode-server/bin"
"$HOME/.vscode/cli/serve-web"
)
for dir in "$${server_dirs[@]}"; do
if [ -d "$dir" ]; then
# Find the most recent server version
local latest
latest=$(ls -t "$dir" 2> /dev/null | head -1)
if [ -n "$latest" ] && [ -f "$dir/$latest/bin/code-server" ]; then
echo "$dir/$latest/bin/code-server"
return 0
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
if [ -n "$latest" ] && [ -f "$dir/$latest/code-server" ]; then
echo "$dir/$latest/code-server"
return 0
fi
fi
done
return 1
}
# Install VS Code CLI if not present
install_code_cli() {
printf "$${BOLD}Installing VS Code CLI...$${RESET}\n"
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64 | arm64) ARCH="arm64" ;;
armv7l) ARCH="armhf" ;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
# Detect platform
# Note: VS Code CLI uses 'alpine' for all Linux distributions
PLATFORM=$(uname -s)
case "$PLATFORM" in
Linux)
PLATFORM="alpine"
;;
Darwin)
PLATFORM="darwin"
;;
*)
echo "Unsupported platform: $PLATFORM"
exit 1
;;
esac
# Create install directory
mkdir -p "${INSTALL_PREFIX}/bin"
# Download VS Code CLI
CLI_URL="https://code.visualstudio.com/sha/download?build=${RELEASE_CHANNEL}&os=cli-$PLATFORM-$ARCH"
printf "Downloading VS Code CLI from %s\n" "$CLI_URL"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$CLI_URL" -o "/tmp/vscode-cli.tar.gz"
elif command -v wget > /dev/null 2>&1; then
wget -q "$CLI_URL" -O "/tmp/vscode-cli.tar.gz"
else
echo "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Extract CLI
tar -xzf /tmp/vscode-cli.tar.gz -C "${INSTALL_PREFIX}/bin"
rm -f /tmp/vscode-cli.tar.gz
# The CLI binary is named 'code'
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
chmod +x "${INSTALL_PREFIX}/bin/code"
export PATH="${INSTALL_PREFIX}/bin:$PATH"
printf "$${BOLD}VS Code CLI installed successfully.$${RESET}\n"
else
echo "Failed to install VS Code CLI"
exit 1
fi
}
# Run VS Code Web using the code CLI (serve-web command)
run_vscode_web_cli() {
local CODE_CMD="$1"
# Build the command arguments
ARGS="serve-web --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
if [ -n "$DISABLE_TRUST_ARG" ]; then
ARGS="$ARGS $DISABLE_TRUST_ARG"
fi
if [ -n "${COMMIT_ID}" ]; then
ARGS="$ARGS --commit-id ${COMMIT_ID}"
fi
printf "Starting VS Code Web on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# shellcheck disable=SC2086
"$CODE_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using code-server (fallback for offline mode)
run_code_server() {
local SERVER_CMD="$1"
printf "Starting code-server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for code-server
ARGS="--port ${PORT} --host 127.0.0.1 --auth none"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using vscode-server binary directly
run_vscode_server() {
local SERVER_CMD="$1"
printf "Starting VS Code Server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for vscode-server
ARGS="--port ${PORT} --host 127.0.0.1 --without-connection-token --accept-server-license-terms --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" serve-local $ARGS > "${LOG_PATH}" 2>&1 &
}
# Install a single extension by downloading VSIX from marketplace
install_extension_vsix() {
local ext_id="$1"
local publisher
local ext_name
publisher="$${ext_id%%.*}"
ext_name="$${ext_id#*.}"
# Download VSIX from marketplace
local vsix_url="https://$publisher.gallery.vsassets.io/_apis/public/gallery/publisher/$publisher/extension/$ext_name/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
local tmp_vsix="/tmp/ext-$ext_id.vsix"
local tmp_dir="/tmp/ext-$ext_id"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$vsix_url" -o "$tmp_vsix" 2> /dev/null
elif command -v wget > /dev/null 2>&1; then
wget -q "$vsix_url" -O "$tmp_vsix" 2> /dev/null
else
echo "Failed to install extension $ext_id: neither curl nor wget available"
return 1
fi
if [ ! -f "$tmp_vsix" ]; then
echo "Failed to download extension: $ext_id"
return 1
fi
# Extract VSIX (it's a ZIP file)
rm -rf "$tmp_dir"
mkdir -p "$tmp_dir"
if ! unzip -q "$tmp_vsix" -d "$tmp_dir" 2> /dev/null; then
echo "Failed to extract extension: $ext_id"
rm -f "$tmp_vsix"
return 1
fi
# Get version from package.json
local version=""
if [ -f "$tmp_dir/extension/package.json" ]; then
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$tmp_dir/extension/package.json" | head -1 | cut -d'"' -f4)
fi
if [ -z "$version" ]; then
version="0.0.0"
fi
# Install to extensions directory
local ext_dir="$HOME/.vscode-server/extensions/$ext_id-$version"
mkdir -p "$HOME/.vscode-server/extensions"
rm -rf "$ext_dir"
mv "$tmp_dir/extension" "$ext_dir"
# Cleanup
rm -rf "$tmp_vsix" "$tmp_dir"
printf "Extension $ext_id v$version installed successfully.\n"
return 0
}
install_extensions() {
# Install specified extensions
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "Installing extension $extension...\n"
install_extension_vsix "$extension"
done
# Auto-install extensions from workspace or folder
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "Installing extensions from %s...\n" "${WORKSPACE}"
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
install_extension_vsix "$extension"
done
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
for extension in $extensions; do
install_extension_vsix "$extension"
done
fi
fi
fi
fi
}
# Apply machine settings (merge with existing if present)
SETTINGS_B64='${SETTINGS_B64}'
if [ -n "$SETTINGS_B64" ]; then
SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d)"
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
fi
run_vscode_web
# Determine which command to use
CODE_CMD=""
RUN_MODE=""
# Check for code CLI first (preferred)
if CODE_CMD=$(check_code_cli); then
printf "$${BOLD}Found VS Code CLI at $CODE_CMD$${RESET}\n"
RUN_MODE="cli"
fi
# Handle offline mode
if [ "${OFFLINE}" = true ]; then
if [ -n "$CODE_CMD" ]; then
# Check if vscode-server is already downloaded (code serve-web won't need to download)
if VSCODE_SERVER=$(find_vscode_server); then
printf "Found cached VS Code Server at $VSCODE_SERVER\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Code CLI exists but vscode-server not cached - try using it anyway
# (it might work if server was pre-downloaded, or fail gracefully)
printf "Warning: VS Code Server may not be cached. Attempting to start...\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Try code-server as fallback for offline mode
if SERVER_CMD=$(check_code_server); then
printf "$${BOLD}Found code-server at $SERVER_CMD (offline fallback)$${RESET}\n"
run_code_server "$SERVER_CMD"
exit 0
fi
# Try vscode-server binary directly
if VSCODE_SERVER=$(find_vscode_server); then
printf "$${BOLD}Found VS Code Server at $VSCODE_SERVER (offline fallback)$${RESET}\n"
run_vscode_server "$VSCODE_SERVER"
exit 0
fi
echo "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found."
exit 1
fi
# Handle use_cached mode
if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then
printf "Using cached VS Code CLI.\n"
install_extensions
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Install VS Code CLI if not present
if [ -z "$CODE_CMD" ]; then
install_code_cli
CODE_CMD="${INSTALL_PREFIX}/bin/code"
RUN_MODE="cli"
fi
# Install extensions and run VS Code Web
install_extensions
run_vscode_web_cli "$CODE_CMD"
@@ -0,0 +1,151 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "accept_license_required" {
command = plan
variables {
agent_id = "foo"
accept_license = false
}
expect_failures = [
var.accept_license
]
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
accept_license = true
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "workspace_and_folder_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
workspace = "/home/coder/project.code-workspace"
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
run "url_with_workspace_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
workspace = "/home/coder/project.code-workspace"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?workspace=%2Fhome%2Fcoder%2Fproject.code-workspace"
error_message = "coder_app URL must include encoded workspace query param"
}
}
run "release_channel_stable" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "stable"
}
}
run "release_channel_insiders" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "insiders"
}
}
run "release_channel_invalid" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "invalid"
}
expect_failures = [
var.release_channel
]
}
run "commit_id_empty_by_default" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "commit_id_with_value" {
command = plan
variables {
agent_id = "foo"
accept_license = true
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
}
}
-38
View File
@@ -11,34 +11,6 @@ set -euo pipefail
#
# This script only validates changed modules. Documentation and template changes are ignored.
# Validates that Terraform variable names use underscores (snake_case) instead
# of hyphens. Hyphens are technically valid but deprecated and non-idiomatic.
# See: https://developer.hashicorp.com/terraform/language/values/variables
validate_variable_names() {
local dir="$1"
local found_issues=0
while IFS= read -r tf_file; do
while IFS= read -r match; do
local line_num
line_num=$(echo "$match" | cut -d: -f1)
local line_content
line_content=$(echo "$match" | cut -d: -f2-)
local var_name
var_name=$(echo "$line_content" | sed -n 's/.*variable "\([^"]*\)".*/\1/p')
if [[ -n "$var_name" ]]; then
echo " ERROR: $tf_file:$line_num"
echo " Variable \"$var_name\" contains a hyphen."
echo " Rename to \"${var_name//-/_}\" (use underscores instead of hyphens)."
found_issues=$((found_issues + 1))
fi
done < <(grep -n 'variable "[^"]*-[^"]*"' "$tf_file" 2> /dev/null || true)
done < <(find "$dir" -name '*.tf' -type f | sort)
return "$found_issues"
}
validate_terraform_directory() {
local dir="$1"
echo "Running \`terraform validate\` in $dir"
@@ -119,16 +91,6 @@ main() {
fi
done
echo ""
echo "==> Validating Terraform variable names use snake_case..."
for dir in $subdirs; do
if test -f "$dir/main.tf"; then
if ! validate_variable_names "$dir"; then
status=1
fi
fi
done
exit $status
}