mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eed8e6c29a | |||
| 7b245549ec | |||
| 2169fb00ee | |||
| e3abbb9aa0 | |||
| 71a4cf2031 | |||
| a0a3783a51 | |||
| eb38bc3092 | |||
| 93e6094b1b | |||
| 6ec506e9b6 | |||
| b794b1edd9 | |||
| 94e41d3780 |
+11
-1
@@ -1 +1,11 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -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.1"
|
||||
version = "4.1.2"
|
||||
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.1"
|
||||
version = "4.1.2"
|
||||
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.1"
|
||||
version = "4.1.2"
|
||||
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.1"
|
||||
version = "4.1.2"
|
||||
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.1"
|
||||
version = "4.1.2"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -464,22 +464,13 @@ 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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,8 +136,8 @@ variable "agentapi_version" {
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
|
||||
default = "gpt-5.2-codex"
|
||||
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
|
||||
default = "gpt-5.3-codex"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
@@ -184,12 +184,13 @@ 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"
|
||||
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"
|
||||
latest_codex_model = "gpt-5.3-codex"
|
||||
aibridge_config = <<-EOF
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
|
||||
@@ -249,6 +250,8 @@ 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,6 +20,8 @@ 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")"
|
||||
@@ -90,15 +92,25 @@ 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,11 +155,8 @@ setup_workdir() {
|
||||
build_codex_args() {
|
||||
CODEX_ARGS=()
|
||||
|
||||
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")
|
||||
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
|
||||
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
|
||||
fi
|
||||
|
||||
if [ "$ARG_CONTINUE" = "true" ]; then
|
||||
@@ -213,7 +210,7 @@ capture_session_id() {
|
||||
|
||||
start_codex() {
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
capture_session_id
|
||||
}
|
||||
|
||||
|
||||
@@ -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.1.1"
|
||||
version = "2.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -62,6 +62,33 @@ 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).
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -258,11 +258,76 @@ 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,
|
||||
@@ -285,10 +350,11 @@ describe("agentapi", async () => {
|
||||
content: coderMock,
|
||||
});
|
||||
|
||||
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
|
||||
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
|
||||
]);
|
||||
|
||||
await execContainer(containerId, [
|
||||
@@ -303,12 +369,25 @@ 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",
|
||||
@@ -318,7 +397,7 @@ describe("agentapi", async () => {
|
||||
return await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
`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`,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -334,6 +413,7 @@ 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);
|
||||
@@ -409,5 +489,128 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,23 @@ 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
|
||||
@@ -182,6 +199,7 @@ 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" {
|
||||
@@ -195,6 +213,7 @@ 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)" \
|
||||
@@ -209,6 +228,9 @@ 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
|
||||
@@ -225,10 +247,14 @@ 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.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -11,6 +11,13 @@ 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:-}"
|
||||
@@ -20,7 +27,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=5
|
||||
readonly FETCH_TIMEOUT=10
|
||||
readonly POST_TIMEOUT=10
|
||||
|
||||
log() {
|
||||
@@ -138,44 +145,45 @@ post_task_log_snapshot() {
|
||||
capture_task_log_snapshot() {
|
||||
if [[ -z $TASK_ID ]]; then
|
||||
log "No task ID, skipping log snapshot"
|
||||
exit 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_URL ]]; then
|
||||
error "CODER_AGENT_URL not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_TOKEN ]]; then
|
||||
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
error "jq not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v curl > /dev/null 2>&1; then
|
||||
error "curl not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Not local, must be visible to the EXIT trap after the function returns.
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
trap 'trap - EXIT; 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"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local message_count
|
||||
message_count=$(jq '.messages | length' < "$payload_file")
|
||||
if ((message_count == 0)); then
|
||||
log "No messages for log snapshot"
|
||||
exit 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Retrieved $message_count messages for log snapshot"
|
||||
@@ -183,7 +191,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"
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local final_size final_count
|
||||
@@ -193,19 +201,60 @@ capture_task_log_snapshot() {
|
||||
|
||||
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
|
||||
error "Log snapshot capture failed"
|
||||
exit 1
|
||||
return 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
|
||||
capture_task_log_snapshot
|
||||
# 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"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/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,8 +16,14 @@ 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
|
||||
}
|
||||
@@ -106,5 +112,18 @@ 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,8 +3,26 @@
|
||||
// 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,12 +6,41 @@ 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);
|
||||
|
||||
@@ -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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -36,6 +36,19 @@ 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
|
||||
@@ -47,7 +60,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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -68,7 +81,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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -97,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -120,7 +133,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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -176,7 +189,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.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -198,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
version = "4.8.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -261,6 +261,12 @@ 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
|
||||
@@ -356,25 +362,26 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.2.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
|
||||
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
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
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,6 +387,36 @@ 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
|
||||
|
||||
|
||||
@@ -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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
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.2"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.0"
|
||||
version = "1.3.2"
|
||||
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.0"
|
||||
version = "1.3.2"
|
||||
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.0"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,20 +54,34 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.2"
|
||||
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.0"
|
||||
version = "1.3.2"
|
||||
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:
|
||||
@@ -76,7 +90,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.0"
|
||||
version = "1.3.2"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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, {
|
||||
|
||||
@@ -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"
|
||||
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."
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.3.1"
|
||||
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.1.0"
|
||||
version = "1.3.1"
|
||||
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.1.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
# Default is "latest"; set to a specific version to pin
|
||||
install_version = "0.4.0"
|
||||
@@ -63,9 +63,24 @@ 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.1.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
add-project = "/path/to/project"
|
||||
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'"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -75,12 +90,40 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.3.1"
|
||||
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:
|
||||
@@ -89,7 +132,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.1.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
use_cached = true
|
||||
}
|
||||
@@ -103,7 +146,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.1.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
install = false
|
||||
}
|
||||
@@ -117,4 +160,6 @@ module "mux" {
|
||||
|
||||
- Mux is currently in preview and you may encounter bugs
|
||||
- Requires internet connectivity for agent operations (unless `install` is set to false)
|
||||
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
executeScriptInContainer,
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
@@ -30,7 +35,7 @@ describe("mux", async () => {
|
||||
}
|
||||
expect(output.exitCode).toBe(0);
|
||||
const expectedLines = [
|
||||
"📥 npm not found; downloading tarball from npm registry...",
|
||||
"📥 No package manager found; downloading tarball from registry...",
|
||||
"🥳 mux has been installed in /tmp/mux",
|
||||
"🚀 Starting mux server on port 4000...",
|
||||
"Check logs at /tmp/mux.log!",
|
||||
@@ -40,6 +45,57 @@ 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",
|
||||
@@ -55,7 +111,7 @@ describe("mux", async () => {
|
||||
expect(output.exitCode).toBe(0);
|
||||
const expectedLines = [
|
||||
"📦 Installing mux via npm into /tmp/mux...",
|
||||
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
|
||||
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
|
||||
"🥳 mux has been installed in /tmp/mux",
|
||||
"🚀 Starting mux server on port 4000...",
|
||||
"Check logs at /tmp/mux.log!",
|
||||
|
||||
@@ -49,18 +49,41 @@ 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"
|
||||
@@ -131,6 +154,7 @@ 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" {
|
||||
@@ -141,11 +165,14 @@ 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,
|
||||
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
|
||||
ADDITIONAL_ARGUMENTS : var.additional_arguments,
|
||||
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
|
||||
|
||||
|
||||
@@ -79,6 +79,20 @@ 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
|
||||
|
||||
@@ -107,3 +121,96 @@ 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,22 @@ 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 &
|
||||
@@ -38,7 +54,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 from npm...\n"
|
||||
printf "$${BOLD}Installing mux...\n"
|
||||
|
||||
# Clean up from other install (in case install prefix changed).
|
||||
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
|
||||
@@ -47,41 +63,76 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
|
||||
|
||||
mkdir -p "$(dirname "$MUX_BINARY")"
|
||||
|
||||
if command -v npm > /dev/null 2>&1; then
|
||||
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
|
||||
# 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}..."
|
||||
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 npm lifecycle scripts with --ignore-scripts"
|
||||
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
|
||||
PKG="mux"
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
|
||||
PKG_SPEC="$PKG@latest"
|
||||
else
|
||||
PKG_SPEC="$PKG@${VERSION}"
|
||||
fi
|
||||
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
|
||||
echo "❌ Failed to install mux via npm"
|
||||
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"
|
||||
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 npm install"
|
||||
echo "❌ Could not locate mux binary after $PM_CMD install"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$CANDIDATE" || true
|
||||
ln -sf "$CANDIDATE" "$MUX_BINARY"
|
||||
else
|
||||
echo "📥 npm not found; downloading tarball from npm registry..."
|
||||
echo "📥 No package manager found; downloading tarball from registry..."
|
||||
VERSION_TO_USE="${VERSION}"
|
||||
if [ -z "$VERSION_TO_USE" ]; then
|
||||
VERSION_TO_USE="next"
|
||||
fi
|
||||
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
|
||||
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
|
||||
META_JSON="$(curl -fsSL "$META_URL" || true)"
|
||||
if [ -z "$META_JSON" ]; then
|
||||
echo "❌ Failed to fetch npm metadata: $META_URL"
|
||||
@@ -120,7 +171,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
|
||||
echo "❌ Could not determine version for mux"
|
||||
exit 1
|
||||
fi
|
||||
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
|
||||
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
|
||||
fi
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
TAR_PATH="$TMP_DIR/mux.tgz"
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.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 = "1.5.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 = "1.5.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 = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -69,6 +69,9 @@ module "vscode-web" {
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 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.
|
||||
|
||||
### 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).
|
||||
@@ -77,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
accept_license = true
|
||||
@@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/coder.code-workspace"
|
||||
}
|
||||
|
||||
@@ -1,42 +1,298 @@
|
||||
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 2 minutes for tests that install packages
|
||||
setDefaultTimeout(2 * 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 () => {
|
||||
it("accept_license should be set to true", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
use_cached: "true",
|
||||
offline: "true",
|
||||
accept_license: false,
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline and Use Cached can not be used together");
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain("Invalid value for variable");
|
||||
}
|
||||
});
|
||||
|
||||
it("offline and extensions can not be used together", () => {
|
||||
const t = async () => {
|
||||
it("use_cached and offline can not be used together", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
offline: "true",
|
||||
extensions: '["1", "2"]',
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
offline: true,
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline mode does not allow extensions to be installed");
|
||||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// More tests depend on shebang refactors
|
||||
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("creates settings file with correct content", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
settings: '{"editor.fontSize": 14}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code-server CLI that the script expects
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// 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("merges settings with existing settings file", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
settings: '{"new.setting": "new_value"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install jq and create mock code-server CLI
|
||||
await execContainer(containerId, ["apt-get", "update", "-qq"]);
|
||||
await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]);
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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");
|
||||
|
||||
const scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// 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("merges settings using python3 fallback when jq unavailable", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
settings: '{"new.setting": "new_value"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install python3 (ubuntu:22.04 doesn't have it by default)
|
||||
await execContainer(containerId, ["apt-get", "update", "-qq"]);
|
||||
await execContainer(containerId, [
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"python3",
|
||||
]);
|
||||
|
||||
// Create mock code-server CLI (no jq installed)
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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");
|
||||
|
||||
const scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
|
||||
// Check that settings were merged using python3 fallback
|
||||
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("preserves existing settings when neither jq nor python3 available", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
settings: '{"new.setting": "new_value"}',
|
||||
});
|
||||
|
||||
// Use ubuntu without installing jq or python3 (neither available by default)
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create mock code-server CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "Mock code-server running"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code-server`,
|
||||
]);
|
||||
|
||||
// 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");
|
||||
|
||||
// Run script - should warn but not fail
|
||||
const scriptResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
expect(scriptResult.exitCode).toBe(0);
|
||||
expect(scriptResult.stdout).toContain("Could not merge settings");
|
||||
|
||||
// Existing settings should be preserved (not overwritten)
|
||||
const settingsResult = await execContainer(containerId, [
|
||||
"cat",
|
||||
"/root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
|
||||
expect(settingsResult.exitCode).toBe(0);
|
||||
expect(settingsResult.stdout).toContain("existing.setting");
|
||||
expect(settingsResult.stdout).toContain("existing_value");
|
||||
expect(settingsResult.stdout).not.toContain("new.setting");
|
||||
expect(settingsResult.stdout).not.toContain("new_value");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,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 = {}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,10 @@ variable "workspace" {
|
||||
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,
|
||||
|
||||
@@ -4,13 +4,54 @@ BOLD='\033[0;1m'
|
||||
EXTENSIONS=("${EXTENSIONS}")
|
||||
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
|
||||
|
||||
# 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 (jq or python3 required). Keeping existing settings.\n"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set extension directory
|
||||
EXTENSION_ARG=""
|
||||
if [ -n "${EXTENSIONS_DIR}" ]; then
|
||||
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
|
||||
fi
|
||||
|
||||
# Set extension directory
|
||||
# Set server base path
|
||||
SERVER_BASE_PATH_ARG=""
|
||||
if [ -n "${SERVER_BASE_PATH}" ]; then
|
||||
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
|
||||
@@ -28,11 +69,14 @@ run_vscode_web() {
|
||||
"$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 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
|
||||
# Apply machine settings (merge with existing if present)
|
||||
SETTINGS_B64='${SETTINGS_B64}'
|
||||
if [ -n "$SETTINGS_B64" ]; then
|
||||
if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then
|
||||
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
|
||||
else
|
||||
printf "Warning: Failed to decode settings. Skipping settings configuration.\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if vscode-server is already installed for offline or cached mode
|
||||
|
||||
@@ -11,6 +11,34 @@ 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"
|
||||
@@ -91,6 +119,16 @@ 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user