Compare commits

...

12 Commits

Author SHA1 Message Date
justmanuel 63e28c0e95 Enable Devcontainer-cli module to block user login until script finishes running (#759)
## Description
Allow for devcontainer-cli module to prevent users from logging in until
its finished running.

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [x ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

**Path:** `registry/coder/modules/devcontainers-cli`  
**New version:** `1.1.0`  
**Breaking change:** [ ] Yes [x ] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [ ] Code formatted (`bun fmt`)
- [ x] Changes tested locally

## Related Issues
None

---------

Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-03 15:01:05 -06:00
DevCats eed8e6c29a feat(vscode-web): enhance settings management and testing for VS Code Web (#758)
This pull request enhances the VS Code Web module by improving how
machine settings are handled and merged, updating documentation to
clarify the settings behavior, and adding robust automated tests for the
new functionality. The most significant changes are grouped below.

**Machine Settings Handling and Merging:**

* Introduced a new `merge_settings` function in `run.sh` that merges
provided settings with any existing machine settings using `jq` or
`python3` if available, falling back gracefully if neither is present.
Settings are now passed as base64-encoded JSON to avoid quoting issues.
[[1]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323R7-R54)
[[2]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323L31-R76)
[[3]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92L180-R184)
[[4]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92R170-R173)
* Updated the `settings` variable in `main.tf` to clarify that it
applies to VS Code Web's Machine settings and will be merged with any
existing settings on startup.

**Documentation Improvements:**

* Updated the README to clarify that settings are merged with existing
machine settings, not simply overwritten, and added a note about the
requirements (`jq` or `python3`) and limitations regarding persistence
of user settings.
[[1]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dL54-R56)
[[2]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dR72-R73)

**Automated Testing:**

* Expanded `main.test.ts` to include integration tests that verify
settings file creation and merging behavior inside a container, as well
as improved error handling for invalid configuration combinations.

These changes collectively make machine settings management more robust,
user-friendly, and well-documented.
2026-03-03 11:30:32 -06:00
Mathias Fredriksson 7b245549ec feat(coder/modules/claude-code): add enable_state_persistence variable (#749)
feat(coder/modules/claude-code): add enable_state_persistence variable

Expose the agentapi module's state persistence toggle so users can
control conversation state persistence across workspace restarts.
Enabled by default, set `enable_state_persistence = false` to disable.

Also bumps agentapi dependency from 2.0.0 to 2.2.0 and claude-code
to 4.8.0.

Refs coder/internal#1258
2026-03-03 18:03:57 +02:00
Mathias Fredriksson 2169fb00ee feat(coder/modules/agentapi): add state persistence support (#736)
AgentAPI can now save and restore conversation state across workspace
restarts. The module exports env vars (AGENTAPI_STATE_FILE,
AGENTAPI_SAVE_STATE, AGENTAPI_LOAD_STATE, AGENTAPI_PID_FILE) that the
binary reads directly. No consumer module changes needed.

New variables: enable_state_persistence (default false),
state_file_path, pid_file_path. State and PID files default to
$HOME/<module_dir_name>/.

Requires agentapi >= v0.12.0. A shared version_at_least function in
lib.sh gates the env var exports and SIGUSR1 in the shutdown script.
Old binaries get a warning and graceful skip.

Shutdown script now does SIGUSR1 (state save), log snapshot capture
(existing, now fault-tolerant via subshell), then SIGTERM with wait.

Closes coder/internal#1257
Refs coder/internal#1256
Refs #696
2026-03-03 13:27:23 +02:00
35C4n0r e3abbb9aa0 maintenance(coder-labs/modules/codex): skip migration notice and add agentapi type flag (#781)
This PR introduces:
1. Adding --type flag to agentapi command
2. Introduce `[notice.model_migrations]` to skip migration notice,
improves tasks UX
3. Set profile = "aibridge" rather than passing it using --profile flag

## Description

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v4.1.2`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

## Related Issues
Closes: #740
2026-03-03 10:35:45 +05:30
Atif Ali 71a4cf2031 chore(coder/modules/mux): update Mux logo (#775)
Update Mux logo
2026-03-02 10:07:58 +05:00
blinkagent[bot] a0a3783a51 docs(dotfiles): add hint about using SSH URLs when HTTPS cloning is restricted (#757)
Some Git providers (e.g. on-prem GitLab) disable HTTPS cloning by
default, which causes the dotfiles clone to silently fail during
workspace startup. Users see "Startup scripts are still running" but the
dotfiles folder is never populated.

This PR adds two small documentation touches:

1. **`main.tf` default description** — appends a one-liner suggesting
SSH URLs when HTTPS is restricted. This is what users see in the Coder
UI parameter prompt.
2. **`README.md`** — new "SSH vs HTTPS URLs" section with an example and
a brief explanation of why SSH URLs are more reliable during startup.

No logic changes, no new variables — just documentation.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-02-27 14:48:55 -06:00
blinkagent[bot] eb38bc3092 ci: add variable naming lint to terraform validate (#766)
## Summary

Terraform variable names should use underscores (`snake_case`), not
hyphens. Hyphens are technically valid in HCL but are [deprecated and
non-idiomatic](https://developer.hashicorp.com/terraform/language/values/variables).
This PR adds a variable name check into the existing
`terraform_validate.sh` script so it runs as part of the existing "Run
Terraform Validate" CI step — no new scripts or workflow changes needed.

## Changes

### `scripts/terraform_validate.sh` — added `validate_variable_names()`
- Scans `.tf` files in changed modules for `variable` declarations with
hyphens
- Fails with actionable fix suggestions (shows the snake_case
alternative)
- Runs after `terraform validate` in the same CI step

### Fix: `code-server` module — rename `machine-settings` →
`machine_settings`
- Renames the hyphenated variable and its reference in main.tf
- Bumps version `1.4.2` → `1.4.3`
- Updates all README examples

---
Created on behalf of @matifali

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-02-27 11:15:47 -06:00
Michael Suchacz 93e6094b1b fix: rename add-project to add_project in mux module (#765)
Terraform variable names should use underscores, not hyphens. Renames
the `add-project` variable to `add_project` in the mux module.

**Changes:**
- `main.tf`: Renamed variable declaration and references
- `README.md`: Updated example usage

Bumped version: 1.3.0 → 1.3.1

---
Generated with [Mux](https://mux.coder.com) using Claude
2026-02-27 10:44:39 -06:00
blinkagent[bot] 6ec506e9b6 fix(dotfiles): allow tilde (~) in git repository URLs (#763)
## Description

The URL validation regex in the dotfiles module was rejecting URLs
containing tilde (`~`) characters, which are commonly used in Bitbucket
Server for user repositories (e.g.
`ssh://git@bitbucket.example.org:7999/~username/repo.git`).

This adds `~` to the allowed character set in all three validation
regexes (for `default_dotfiles_uri`, `dotfiles_uri`, and the
`coder_parameter` validation).

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

**Path:** `registry/coder/modules/dotfiles`  
**New version:** `v1.3.1`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [ ] Changes tested locally

## Related Issues

Fixes #762

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-02-26 13:30:20 -06:00
Michael Suchacz b794b1edd9 feat(mux): add package_manager and registry_url variables (#761)
## Summary

Add two new customization variables to the Mux module so users can
control how Mux is installed:

### `package_manager` (default: `"auto"`)

Choose which Node package manager installs Mux:

- **`auto`** (default) — auto-detects `npm` → `pnpm` → `bun` in order,
falling back to a direct tarball download when none is available
- **`npm`**, **`pnpm`**, **`bun`** — force a specific package manager
(fails if not found on PATH)

### `registry_url` (default: `"https://registry.npmjs.org"`)

Override the npm registry URL for private registries or mirrors. All
previously hardcoded `registry.npmjs.org` references have been replaced
with this variable. The `--registry` flag is passed to whichever package
manager is used, and the tarball fallback path also uses it.

## Changes

| File | What changed |
|---|---|
| `main.tf` | Added `package_manager` and `registry_url` variables with
validation; pass both to template |
| `run.sh` | Rewrote install logic: PM auto-detection loop,
`case`/`esac` dispatch with PM-specific flags, replaced all hardcoded
registry URLs with `${REGISTRY_URL}` |
| `mux.tftest.hcl` | Added 6 new test cases: PM selection
(npm/pnpm/bun), invalid PM validation, custom registry URL,
trailing-slash stripping |
| `main.test.ts` | Updated expected log messages to match new generic
wording |
| `README.md` | Updated description, added Custom Package Manager and
Custom Registry examples, updated Notes section |

## Version

Bumped **1.2.0 → 1.3.0** (minor: new backward-compatible features).

## Validation

-  `terraform validate` — clean
-  `terraform test` — **15 passed, 0 failed**
-  `terraform fmt` — clean

---

Generated with [Mux](https://mux.coder.com) using Claude
2026-02-26 16:40:40 +01:00
Michael Suchacz 94e41d3780 Add arbitrary mux server command argument parsing (#738)
## Summary
- add a new `additional_arguments` module variable to pass extra
arguments to `mux server`
- parse `additional_arguments` in `run.sh` with quoted-group support so
values like paths with spaces are preserved
- keep existing `add-project` behavior while allowing additional
arbitrary flags
- add Terraform and Bun tests covering `additional_arguments` behavior
- document the new option in the module README and bump example version
references to `1.2.0`

## Why
The module previously only supported the `add-project` flag. This change
lets users pass additional `mux server` arguments without waiting for
new module variables.

## Validation
- `shellcheck --severity=warning --format=gcc
registry/coder/modules/mux/run.sh`
- `terraform -chdir=registry/coder/modules/mux test -verbose`
- `bun test registry/coder/modules/mux/main.test.ts`

## Breaking changes
None.

---
Generated with Mux (exec agent) using GPT-5.
2026-02-25 18:15:33 +00:00
35 changed files with 1412 additions and 172 deletions
+11 -1
View File
@@ -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

+5 -5
View File
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1" version = "4.1.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key openai_api_key = var.openai_api_key
workdir = "/home/coder/project" workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" { module "codex" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1" version = "4.1.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1" version = "4.1.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_aibridge = true enable_aibridge = true
@@ -94,7 +94,7 @@ data "coder_task" "me" {}
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1" version = "4.1.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt ai_prompt = data.coder_task.me.prompt
@@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1" version = "4.1.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"
+1 -10
View File
@@ -464,22 +464,13 @@ describe("codex", async () => {
}); });
await execModuleScript(id); await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer( const configToml = await readFileContainer(
id, id,
"/home/coder/.codex/config.toml", "/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( expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"', "[profiles.aibridge]\n" + 'model_provider = "aibridge"',
); );
expect(configToml).toContain('profile = "aibridge"');
}); });
}); });
+11 -8
View File
@@ -136,8 +136,8 @@ variable "agentapi_version" {
variable "codex_model" { variable "codex_model" {
type = string type = string
description = "The model for Codex to use. Defaults to gpt-5.2-codex." description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.2-codex" default = "gpt-5.3-codex"
} }
variable "pre_install_script" { variable "pre_install_script" {
@@ -184,12 +184,13 @@ resource "coder_env" "coder_aibridge_session_token" {
} }
locals { locals {
workdir = trimsuffix(var.workdir, "/") workdir = trimsuffix(var.workdir, "/")
app_slug = "codex" app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh") install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh") start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module" module_dir_name = ".codex-module"
aibridge_config = <<-EOF latest_codex_model = "gpt-5.3-codex"
aibridge_config = <<-EOF
[model_providers.aibridge] [model_providers.aibridge]
name = "AI Bridge" name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
@@ -249,6 +250,8 @@ module "agentapi" {
chmod +x /tmp/install.sh chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \ ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \ 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_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \ ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \ 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 "Install Codex: %s\n" "$ARG_INSTALL"
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION" printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG" 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 "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 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")" 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() { write_minimal_default_config() {
local config_path="$1" local config_path="$1"
ARG_DEFAULT_PROFILE=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_DEFAULT_PROFILE='profile = "aibridge"'
fi
cat << EOF > "$config_path" cat << EOF > "$config_path"
# Minimal Default Codex Configuration # Minimal Default Codex Configuration
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
approval_policy = "never" approval_policy = "never"
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
${ARG_DEFAULT_PROFILE}
[sandbox_workspace_write] [sandbox_workspace_write]
network_access = true network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
EOF EOF
} }
@@ -155,11 +155,8 @@ setup_workdir() {
build_codex_args() { build_codex_args() {
CODEX_ARGS=() CODEX_ARGS=()
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
printf "AI Bridge is enabled, using profile aibridge\n" CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi fi
if [ "$ARG_CONTINUE" = "true" ]; then if [ "$ARG_CONTINUE" = "true" ]; then
@@ -213,7 +210,7 @@ capture_session_id() {
start_codex() { start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" 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 capture_session_id
} }
+28 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf ```tf
module "agentapi" { module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder" source = "registry.coder.com/coder/agentapi/coder"
version = "2.1.1" version = "2.2.0"
agent_id = var.agent_id agent_id = var.agent_id
web_app_slug = local.app_slug 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 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). 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"
}
}
+205 -2
View File
@@ -258,11 +258,76 @@ describe("agentapi", async () => {
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); 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 () => { describe("shutdown script", async () => {
const setupMocks = async ( const setupMocks = async (
containerId: string, containerId: string,
agentapiPreset: string, agentapiPreset: string,
httpCode: number = 204, httpCode: number = 204,
pidFilePath: string = "",
) => { ) => {
const agentapiMock = await loadTestFile( const agentapiMock = await loadTestFile(
import.meta.dir, import.meta.dir,
@@ -285,10 +350,11 @@ describe("agentapi", async () => {
content: coderMock, content: coderMock,
}); });
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
await execContainer(containerId, [ await execContainer(containerId, [
"bash", "bash",
"-c", "-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, [ await execContainer(containerId, [
@@ -303,12 +369,25 @@ describe("agentapi", async () => {
const runShutdownScript = async ( const runShutdownScript = async (
containerId: string, containerId: string,
taskId: string = "test-task", taskId: string = "test-task",
pidFilePath: string = "",
enableStatePersistence: string = "false",
) => { ) => {
const shutdownScript = await loadTestFile( const shutdownScript = await loadTestFile(
import.meta.dir, import.meta.dir,
"../scripts/agentapi-shutdown.sh", "../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({ await writeExecutable({
containerId, containerId,
filePath: "/tmp/shutdown.sh", filePath: "/tmp/shutdown.sh",
@@ -318,7 +397,7 @@ describe("agentapi", async () => {
return await execContainer(containerId, [ return await execContainer(containerId, [
"bash", "bash",
"-c", "-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.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully"); 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 posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted); const snapshot = JSON.parse(posted);
@@ -409,5 +489,128 @@ describe("agentapi", async () => {
"Log snapshot endpoint not supported by this Coder version", "Log snapshot endpoint not supported by this Coder version",
); );
}); });
test("sends SIGUSR1 before shutdown", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
expect(sigusr1Log).toContain("SIGUSR1 received");
});
test("handles missing PID file gracefully", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
// Pass a non-existent PID file path with persistence enabled to
// exercise the SIGUSR1 path with a missing PID.
const result = await runShutdownScript(
id,
"test-task",
"/tmp/nonexistent.pid",
"true",
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Shutdown complete");
});
test("sends SIGTERM even when snapshot fails", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
// HTTP 500 will cause snapshot to fail
await setupMocks(id, "normal", 500, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot capture failed, continuing shutdown",
);
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("resolves default PID path from MODULE_DIR_NAME", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
// Start mock with PID file at the module_dir_name default location.
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
await setupMocks(id, "normal", 204, defaultPidPath);
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId: id,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId: id,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
const result = await execContainer(id, [
"bash",
"-c",
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("skips SIGUSR1 when no PID file available", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 204);
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
const result = await runShutdownScript(id, "test-task", "", "false");
expect(result.exitCode).toBe(0);
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
expect(result.stdout).not.toContain("Sending SIGUSR1");
expect(result.stdout).not.toContain("Sending SIGTERM");
expect(result.stdout).toContain("Shutdown complete");
});
test("skips SIGUSR1 when state persistence disabled", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
// PID file exists but state persistence is disabled.
const result = await runShutdownScript(id, "test-task", pidFile, "false");
expect(result.exitCode).toBe(0);
// Should NOT send SIGUSR1 (persistence disabled).
expect(result.stdout).not.toContain("Sending SIGUSR1");
// Should still send SIGTERM (graceful shutdown always happens).
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
}); });
}); });
+26
View File
@@ -164,6 +164,23 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files." 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 { locals {
# we always trim the slash for consistency # 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" 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") main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
} }
resource "coder_script" "agentapi" { resource "coder_script" "agentapi" {
@@ -195,6 +213,7 @@ resource "coder_script" "agentapi" {
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /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_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ 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_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ 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 /tmp/main.sh
EOT EOT
run_on_start = true 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 echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /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_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \ 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 /tmp/agentapi-shutdown.sh
EOT EOT
} }
@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# AgentAPI shutdown script. # AgentAPI shutdown script.
# #
# Captures the last 10 messages from AgentAPI and posts them to Coder instance # Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save,
# as a snapshot. This script is called during workspace shutdown to access # captures the last 10 messages as a log snapshot posted to the Coder instance,
# conversation history for paused tasks. # then sends SIGTERM for graceful termination.
set -euo pipefail set -euo pipefail
@@ -11,6 +11,13 @@ set -euo pipefail
readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" 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. # Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" 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_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10 readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=5 readonly FETCH_TIMEOUT=10
readonly POST_TIMEOUT=10 readonly POST_TIMEOUT=10
log() { log() {
@@ -138,44 +145,45 @@ post_task_log_snapshot() {
capture_task_log_snapshot() { capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot" log "No task ID, skipping log snapshot"
exit 0 return 0
fi fi
if [[ -z $CODER_AGENT_URL ]]; then if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot" error "CODER_AGENT_URL not set, cannot capture log snapshot"
exit 1 return 1
fi fi
if [[ -z $CODER_AGENT_TOKEN ]]; then if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
exit 1 return 1
fi fi
if ! command -v jq > /dev/null 2>&1; then if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot" error "jq not found, cannot capture log snapshot"
exit 1 return 1
fi fi
if ! command -v curl > /dev/null 2>&1; then if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot" error "curl not found, cannot capture log snapshot"
exit 1 return 1
fi fi
# Not local, must be visible to the EXIT trap after the function returns.
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json" local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages" error "Cannot capture log snapshot without messages"
exit 1 return 1
fi fi
local message_count local message_count
message_count=$(jq '.messages | length' < "$payload_file") message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then if ((message_count == 0)); then
log "No messages for log snapshot" log "No messages for log snapshot"
exit 0 return 0
fi fi
log "Retrieved $message_count messages for log snapshot" log "Retrieved $message_count messages for log snapshot"
@@ -183,7 +191,7 @@ capture_task_log_snapshot() {
# Ensure payload fits within size limit. # Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit" error "Failed to truncate payload to size limit"
exit 1 return 1
fi fi
local final_size final_count local final_size final_count
@@ -193,19 +201,60 @@ capture_task_log_snapshot() {
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed" error "Log snapshot capture failed"
exit 1 return 1
fi fi
} }
main() { main() {
log "Shutting down AgentAPI" 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 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 else
log "Log snapshot disabled, skipping" log "Log snapshot disabled, skipping"
fi 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" 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:-}" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}" TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" 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 set +o nounset
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
command_exists() { command_exists() {
command -v "$1" > /dev/null 2>&1 command -v "$1" > /dev/null 2>&1
} }
@@ -106,5 +112,18 @@ cd "${WORKDIR}"
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation) # Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*" 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" & 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}" "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
@@ -3,8 +3,26 @@
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] // Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http"); const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 3284; 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 // Parse messages from environment or use default
let messages = []; let messages = [];
if (process.env.MESSAGES) { if (process.env.MESSAGES) {
@@ -6,12 +6,41 @@ const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1; const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284; 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}`); console.log(`starting server on port ${port}`);
fs.writeFileSync( fs.writeFileSync(
"/home/coder/agentapi-mock.log", "/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, `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 http
.createServer(function (_request, response) { .createServer(function (_request, response) {
response.writeHead(200); response.writeHead(200);
+22 -9
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx" 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` 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 ## Examples
### Usage with Agent Boundaries ### Usage with Agent Boundaries
@@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_boundary = true enable_boundary = true
@@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_aibridge = true enable_aibridge = true
@@ -97,7 +110,7 @@ data "coder_task" "me" {}
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt ai_prompt = data.coder_task.me.prompt
@@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
@@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
install_claude_code = true install_claude_code = true
@@ -198,7 +211,7 @@ variable "claude_code_oauth_token" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token claude_code_oauth_token = var.claude_code_oauth_token
@@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5" version = "4.8.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514" model = "claude-sonnet-4@20250514"
+25 -18
View File
@@ -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" { resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1 count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id agent_id = var.agent_id
@@ -356,25 +362,26 @@ locals {
module "agentapi" { module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder" source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0" version = "2.2.0"
agent_id = var.agent_id agent_id = var.agent_id
web_app_slug = local.app_slug web_app_slug = local.app_slug
web_app_order = var.order web_app_order = var.order
web_app_group = var.group web_app_group = var.group
web_app_icon = var.icon web_app_icon = var.icon
web_app_display_name = var.web_app_display_name web_app_display_name = var.web_app_display_name
folder = local.workdir folder = local.workdir
cli_app = var.cli_app cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script enable_state_persistence = var.enable_state_persistence
post_install_script = var.post_install_script pre_install_script = var.pre_install_script
start_script = <<-EOT post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash #!/bin/bash
set -o errexit set -o errexit
set -o pipefail 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" { run "test_no_api_key_no_env" {
command = plan command = plan
+8 -8
View File
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
install_version = "4.106.3" install_version = "4.106.3"
} }
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = [ extensions = [
"dracula-theme.theme-dracula" "dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"] extensions = ["dracula-theme.theme-dracula"]
settings = { settings = {
@@ -78,7 +78,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] 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" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust" 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" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
use_cached = true use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] 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" { module "code-server" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder" source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2" version = "1.4.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
offline = true offline = true
} }
+2 -2
View File
@@ -44,7 +44,7 @@ variable "settings" {
default = {} default = {}
} }
variable "machine-settings" { variable "machine_settings" {
type = any type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start." description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {} default = {}
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix, INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped! // This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""), MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
OFFLINE : var.offline, OFFLINE : var.offline,
USE_CACHED : var.use_cached, USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions, USE_CACHED_EXTENSIONS : var.use_cached_extensions,
@@ -14,8 +14,9 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
```tf ```tf
module "devcontainers-cli" { module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder" source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.34" version = "1.1.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
start_blocks_login = false
} }
``` ```
@@ -14,10 +14,17 @@ variable "agent_id" {
description = "The ID of a Coder agent." description = "The ID of a Coder agent."
} }
resource "coder_script" "devcontainers-cli" { variable "start_blocks_login" {
agent_id = var.agent_id type = bool
display_name = "devcontainers-cli" default = false
icon = "/icon/devcontainers.svg" description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup."
script = templatefile("${path.module}/run.sh", {}) }
run_on_start = true
resource "coder_script" "devcontainers-cli" {
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
start_blocks_login = var.start_blocks_login
} }
+20 -6
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
user = "root" user = "root"
} }
@@ -54,20 +54,34 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
module "dotfiles-root" { module "dotfiles-root" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
user = "root" user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri 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 ## Setting a default dotfiles repository
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable: 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" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.0" version = "1.3.2"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles" 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",
"git://github.com/coder/dotfiles.git", "git://github.com/coder/dotfiles.git",
"ssh://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) { for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
+4 -4
View File
@@ -29,7 +29,7 @@ variable "agent_id" {
variable "description" { variable "description" {
type = string 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." 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" { variable "default_dotfiles_uri" {
@@ -40,7 +40,7 @@ variable "default_dotfiles_uri" {
validation { validation {
condition = ( condition = (
var.default_dotfiles_uri == "" || 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." error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
} }
@@ -55,7 +55,7 @@ variable "dotfiles_uri" {
condition = ( condition = (
var.dotfiles_uri == null || var.dotfiles_uri == null ||
var.dotfiles_uri == "" || 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." 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" icon = "/icon/dotfiles.svg"
validation { 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." error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
} }
} }
+55 -10
View File
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux # 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 ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin # Default is "latest"; set to a specific version to pin
install_version = "0.4.0" install_version = "0.4.0"
@@ -63,9 +63,24 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id 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" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
port = 8080 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 ### Use Cached Installation
Run an existing copy of Mux if found, otherwise install from npm: 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" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
use_cached = true use_cached = true
} }
@@ -103,7 +146,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.1.0" version = "1.3.1"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
install = false install = false
} }
@@ -117,4 +160,6 @@ module "mux" {
- Mux is currently in preview and you may encounter bugs - Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false) - 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
+58 -2
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
executeScriptInContainer, executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply, runTerraformApply,
runTerraformInit, runTerraformInit,
testRequiredVariables, testRequiredVariables,
@@ -30,7 +35,7 @@ describe("mux", async () => {
} }
expect(output.exitCode).toBe(0); expect(output.exitCode).toBe(0);
const expectedLines = [ 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", "🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...", "🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!", "Check logs at /tmp/mux.log!",
@@ -40,6 +45,57 @@ describe("mux", async () => {
} }
}, 60000); }, 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 () => { it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
@@ -55,7 +111,7 @@ describe("mux", async () => {
expect(output.exitCode).toBe(0); expect(output.exitCode).toBe(0);
const expectedLines = [ const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...", "📦 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", "🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...", "🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!", "Check logs at /tmp/mux.log!",
+29 -2
View File
@@ -49,18 +49,41 @@ variable "log_path" {
default = "/tmp/mux.log" default = "/tmp/mux.log"
} }
variable "add-project" { variable "add_project" {
type = string type = string
description = "Optional path to add/open as a project in Mux on startup." description = "Optional path to add/open as a project in Mux on startup."
default = null 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" { variable "install_version" {
type = string type = string
description = "The version or dist-tag of Mux to install." description = "The version or dist-tag of Mux to install."
default = "next" 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" { variable "share" {
type = string type = string
default = "owner" default = "owner"
@@ -131,6 +154,7 @@ resource "random_password" "mux_auth_token" {
locals { locals {
mux_auth_token = random_password.mux_auth_token.result mux_auth_token = random_password.mux_auth_token.result
registry_url = trimsuffix(var.registry_url, "/")
} }
resource "coder_script" "mux" { resource "coder_script" "mux" {
@@ -141,11 +165,14 @@ resource "coder_script" "mux" {
VERSION : var.install_version, VERSION : var.install_version,
PORT : var.port, PORT : var.port,
LOG_PATH : var.log_path, 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, INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install, OFFLINE : !var.install,
USE_CACHED : var.use_cached, USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token, AUTH_TOKEN : local.mux_auth_token,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
}) })
run_on_start = true run_on_start = true
+107
View File
@@ -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" { run "custom_version" {
command = plan command = plan
@@ -107,3 +121,96 @@ run "use_cached_only_success" {
use_cached = true 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"
}
}
+61 -10
View File
@@ -20,6 +20,22 @@ function run_mux() {
if [ -n "${ADD_PROJECT}" ]; then if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}" set -- "$@" --add-project "${ADD_PROJECT}"
fi 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 "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!" echo "Check logs at ${LOG_PATH}!"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 & 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 there is no cached install OR we don't want to use a cached install
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then 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). # Clean up from other install (in case install prefix changed).
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then 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")" mkdir -p "$(dirname "$MUX_BINARY")"
if command -v npm > /dev/null 2>&1; then # Determine which package manager to use
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..." 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" NPM_WORKDIR="${INSTALL_PREFIX}/npm"
mkdir -p "$NPM_WORKDIR" mkdir -p "$NPM_WORKDIR"
cd "$NPM_WORKDIR" || exit 1 cd "$NPM_WORKDIR" || exit 1
if [ ! -f package.json ]; then if [ ! -f package.json ]; then
echo '{}' > package.json echo '{}' > package.json
fi fi
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts" echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
PKG="mux" PKG="mux"
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
PKG_SPEC="$PKG@latest" PKG_SPEC="$PKG@latest"
else else
PKG_SPEC="$PKG@${VERSION}" PKG_SPEC="$PKG@${VERSION}"
fi fi
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then INSTALL_OK=true
echo "❌ Failed to install mux via npm" 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 exit 1
fi fi
# Determine the installed binary path # Determine the installed binary path
BIN_DIR="$NPM_WORKDIR/node_modules/.bin" BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
CANDIDATE="$BIN_DIR/mux" CANDIDATE="$BIN_DIR/mux"
if [ ! -f "$CANDIDATE" ]; then 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 exit 1
fi fi
chmod +x "$CANDIDATE" || true chmod +x "$CANDIDATE" || true
ln -sf "$CANDIDATE" "$MUX_BINARY" ln -sf "$CANDIDATE" "$MUX_BINARY"
else else
echo "📥 npm not found; downloading tarball from npm registry..." echo "📥 No package manager found; downloading tarball from registry..."
VERSION_TO_USE="${VERSION}" VERSION_TO_USE="${VERSION}"
if [ -z "$VERSION_TO_USE" ]; then if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE="next" VERSION_TO_USE="next"
fi 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)" META_JSON="$(curl -fsSL "$META_URL" || true)"
if [ -z "$META_JSON" ]; then if [ -z "$META_JSON" ]; then
echo "❌ Failed to fetch npm metadata: $META_URL" 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" echo "❌ Could not determine version for mux"
exit 1 exit 1
fi fi
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz" TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
fi fi
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
TAR_PATH="$TMP_DIR/mux.tgz" TAR_PATH="$TMP_DIR/mux.tgz"
+11 -8
View File
@@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
module "vscode-web" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
accept_license = true accept_license = true
} }
@@ -30,7 +30,7 @@ module "vscode-web" {
module "vscode-web" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web" install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder" folder = "/home/coder"
@@ -44,22 +44,22 @@ module "vscode-web" {
module "vscode-web" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true 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 ```tf
module "vscode-web" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"] extensions = ["dracula-theme.theme-dracula"]
settings = { 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 ### 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). 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" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true accept_license = true
@@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
module "vscode-web" { module "vscode-web" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder" source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3" version = "1.5.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace" workspace = "/home/coder/coder.code-workspace"
} }
+283 -27
View File
@@ -1,42 +1,298 @@
import { describe, expect, it } from "bun:test"; import {
import { runTerraformApply, runTerraformInit } from "~test"; 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 () => { describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir); beforeAll(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");
}); });
it("use_cached and offline can not be used together", () => { it("accept_license should be set to true", async () => {
const t = async () => { try {
await runTerraformApply(import.meta.dir, { await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
accept_license: "true", accept_license: false,
use_cached: "true",
offline: "true",
}); });
}; throw new Error("Expected terraform apply to fail");
expect(t).toThrow("Offline and Use Cached can not be used together"); } catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
}); });
it("offline and extensions can not be used together", () => { it("use_cached and offline can not be used together", async () => {
const t = async () => { try {
await runTerraformApply(import.meta.dir, { await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
accept_license: "true", accept_license: true,
offline: "true", use_cached: true,
extensions: '["1", "2"]', offline: true,
}); });
}; throw new Error("Expected terraform apply to fail");
expect(t).toThrow("Offline mode does not allow extensions to be installed"); } 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");
});
}); });
+6 -3
View File
@@ -105,7 +105,7 @@ variable "group" {
variable "settings" { variable "settings" {
type = any 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 = {} default = {}
} }
@@ -167,6 +167,10 @@ variable "workspace" {
data "coder_workspace_owner" "me" {} data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {} data "coder_workspace" "me" {}
locals {
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
}
resource "coder_script" "vscode-web" { resource "coder_script" "vscode-web" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "VS Code Web" display_name = "VS Code Web"
@@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" {
INSTALL_PREFIX : var.install_prefix, INSTALL_PREFIX : var.install_prefix,
EXTENSIONS : join(",", var.extensions), EXTENSIONS : join(",", var.extensions),
TELEMETRY_LEVEL : var.telemetry_level, TELEMETRY_LEVEL : var.telemetry_level,
// This is necessary otherwise the quotes are stripped! SETTINGS_B64 : local.settings_b64,
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
OFFLINE : var.offline, OFFLINE : var.offline,
USE_CACHED : var.use_cached, USE_CACHED : var.use_cached,
DISABLE_TRUST : var.disable_trust, DISABLE_TRUST : var.disable_trust,
+50 -6
View File
@@ -4,13 +4,54 @@ BOLD='\033[0;1m'
EXTENSIONS=("${EXTENSIONS}") EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" 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 # Set extension directory
EXTENSION_ARG="" EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi fi
# Set extension directory # Set server base path
SERVER_BASE_PATH_ARG="" SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" 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 & "$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... # Apply machine settings (merge with existing if present)
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then SETTINGS_B64='${SETTINGS_B64}'
echo "⚙️ Creating settings file..." if [ -n "$SETTINGS_B64" ]; then
mkdir -p ~/.vscode-server/data/Machine if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
else
printf "Warning: Failed to decode settings. Skipping settings configuration.\n"
fi
fi fi
# Check if vscode-server is already installed for offline or cached mode # Check if vscode-server is already installed for offline or cached mode
+38
View File
@@ -11,6 +11,34 @@ set -euo pipefail
# #
# This script only validates changed modules. Documentation and template changes are ignored. # 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() { validate_terraform_directory() {
local dir="$1" local dir="$1"
echo "Running \`terraform validate\` in $dir" echo "Running \`terraform validate\` in $dir"
@@ -91,6 +119,16 @@ main() {
fi fi
done 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 exit $status
} }