Compare commits

..

15 Commits

Author SHA1 Message Date
DevelopmentCats c5f6a00851 chore: bun fmt 2026-02-24 15:35:12 -06:00
DevelopmentCats 05e6324e41 fix: refactor extension installation in VS Code Web to use VSIX downloads 2026-02-24 15:19:49 -06:00
DevelopmentCats fd6f980610 fix: enhance extension installation process in VS Code Web by utilizing remote CLI 2026-02-24 14:56:13 -06:00
DevelopmentCats 3447d31392 fix: enhance VS Code Web server readiness check to use log output 2026-02-24 14:32:52 -06:00
DevelopmentCats 618a9b8b5d fix: improve VS Code Web server readiness check before installing extensions
- Added a function to wait for the VS Code Web server to be ready before proceeding with the installation of extensions.
- Replaced sleep commands with a conditional wait to ensure extensions are only installed after the server is confirmed to be running.
2026-02-24 14:26:22 -06:00
DevelopmentCats 847c9491af fix: adjust VS Code Web CLI execution order to ensure extensions are installed after server starts
- Added a sleep command to allow the VS Code Web server to start before installing extensions.
- Modified the script to run the VS Code Web CLI first, followed by the installation of extensions.
2026-02-24 14:02:29 -06:00
DevCats 10142cbe1c Merge branch 'main' into vscode-web-cli 2026-02-24 13:26:43 -06:00
DevelopmentCats 8ec817e33c refactor: update VS Code Web settings handling to merge with existing settings
- Changed test description to reflect merging behavior of settings.
- Updated Terraform variable description to clarify merging of settings.
- Implemented a new function in the run script to merge settings using jq or Python3.
- Adjusted the settings file creation logic to merge new settings with existing ones.
- Updated README to reflect changes in settings configuration and merging requirements.
2026-02-24 13:26:19 -06:00
Atif Ali 08bd84c529 Merge branch 'main' into vscode-web-cli 2026-02-10 10:25:08 +05:00
blink-so[bot] c493bbd490 fix: resolve SC2155 shellcheck warning in vscode-web run.sh
Separate declaration and assignment for local variable to avoid masking return values.
2026-01-12 15:47:22 +00:00
Atif Ali b55d546f03 Merge branch 'main' into vscode-web-cli 2026-01-09 17:59:39 +05:00
Atif Ali f1d5947245 Merge branch 'main' into vscode-web-cli 2026-01-08 00:23:11 +05:00
Muhammad Atif Ali e1eda2ce65 bunfmt 2026-01-05 12:20:27 +05:00
Atif Ali a1eed799aa Merge branch 'main' into vscode-web-cli 2026-01-05 12:18:55 +05:00
Muhammad Atif Ali b52c0f9f63 refactor(vscode-web): migrate to VS Code CLI with code serve-web
- Replace code-server with official VS Code CLI
- Download CLI from code.visualstudio.com using cli-alpine-* URLs
- Add release_channel variable (stable/insiders)
- Add commit_id variable to pin specific VS Code versions
- Support offline mode with fallback to code-server or cached vscode-server
- Add comprehensive bun tests for settings, extensions, and CLI arguments
- Add Terraform tests for variable validation
2025-12-31 16:28:08 +05:00
127 changed files with 4733 additions and 10504 deletions
-369
View File
@@ -1,369 +0,0 @@
---
name: coder-modules
description: Creates and updates Coder Registry modules with proper scaffolding, Terraform testing, README frontmatter, and version management
---
# Coder Modules
Coder Registry modules are reusable Terraform components that live under `registry/<namespace>/modules/<name>/` and are consumed by templates via `module` blocks.
## Before You Start
Before writing or modifying any code:
1. **Understand the request.** What tool, integration, or functionality is the module providing? What Coder resources does it need (`coder_script`, `coder_app`, `coder_env`, etc.)? Read the official documentation for the target tool or integration (installation steps, CLI flags, config files, environment variables, ports) so you can implement the module properly without guessing.
2. **Research existing modules.** Search the registry for similar modules. Read their `main.tf` to understand patterns, variable conventions, and how they solve similar problems. Avoid duplicating existing functionality.
3. **Check the Coder provider docs.** Verify that the resources and attributes you plan to use exist in the provider version you're targeting. Use the version-specific docs URL if needed.
4. **Clarify before building.** If the request is ambiguous (e.g. unclear which Coder resource to use, whether a `coder_app` vs `coder_script` is appropriate, what variables to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
5. **Plan the structure.** Decide on script organization (root `run.sh`, `scripts/` directory, or inline), what variables to expose, and what tests to write.
Always prefer the proper implementation over a simpler shortcut. Modules are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the module incomplete or fragile.
## Documentation References
### Coder
- Coder docs (latest): <https://coder.com/docs>
- Version-specific Coder docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
- Coder Registry: <https://registry.coder.com>
### Coder Terraform provider
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
Resources:
| Resource | Docs |
| ---------------- | ------------------------------------------------------------------------------------ |
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
Data sources:
| Data Source | Docs |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
## Scaffolding a New Module
Only use this when creating a brand new module that does not yet exist. When updating an existing module, edit its files directly.
From repo root:
```bash
./scripts/new_module.sh namespace/module-name
```
Names must be lowercase alphanumeric with hyphens (e.g. `coder/my-tool`). Underscores are not allowed.
Creates `registry/<namespace>/modules/<module-name>/` with:
- `main.tf`: Terraform config with common resource patterns and variables — read this as the primary reference for module structure
- `README.md`: frontmatter and usage examples
- `MODULE_NAME.tftest.hcl`: Terraform native tests
- `run.sh`: install/start-up script template
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
## Key Patterns
- Provider version constraints must reflect actual functionality requirements. Only raise the minimum `coder` provider version (e.g. `>= 2.5` to `>= 2.8`) when the module uses a resource, attribute, or behavior introduced in that version; check the provider changelog to confirm.
- Variable names MUST be `snake_case` (no hyphens; validation rejects them)
- New variables must have sensible defaults for backward compatibility
- Common variable: `agent_id` (string, required, no default)
- Common variable: `order` (number, default `null`, controls UI position)
- Use `locals {}` for computed values: URL normalization, base64 encoding, `file()` script content, config assembly
- Modules can consume other registry modules via `module` blocks (e.g. `cursor` uses `vscode-desktop-core`, CLI wrappers use `agentapi`). Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. If you are inside the registry repo, read these files directly. Otherwise, read the module's page at `https://registry.coder.com/modules/<namespace>/<module-name>` which includes the full source, README, and variable definitions. Never pass arguments without confirming they exist.
- Most modules expose configuration via `variable` blocks, letting the template pass values. Use `coder_parameter` inside a module only when the module needs to present a UI choice directly to the workspace user (e.g. region selectors, IDE pickers).
- For parameter-only modules (region selectors, etc.), use `dynamic "option"` with `for_each` from a `locals` map and expose an `output` for the selected value.
- `coder_script` icons use the `/icon/<name>.svg` format. The `display_name` is typically the product name (e.g. "code-server", "Git Clone", "File Browser").
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
## README.md
Required YAML frontmatter:
```yaml
---
display_name: My Tool
description: Short description of what this module does
icon: ../../../../.icons/tool.svg
verified: false
tags: [helper, ide]
---
```
Content rules:
- Single H1 heading matching `display_name`, directly below frontmatter
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
- Usage snippet with `registry.coder.com/<ns>/<module>/coder` and pinned `version`
- Code fences labeled `tf` (NOT `hcl`)
- Relative icon paths (e.g. `../../../../.icons/`)
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Describe what the module does and how to use it in prose, not by listing every configurable field.
- Usage examples are encouraged
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
```tf
module "my_tool" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/namespace/my-tool/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
}
```
## Icons
Modules reference icons in two places with different path systems:
- **README frontmatter** `icon:` uses a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/my-tool.svg`). Displayed on the registry website.
- **`coder_script` / `coder_app`** `icon =` uses an absolute `/icon/<name>.svg` path served by the Coder deployment from `site/static/icon/` in the `coder/coder` repo. Displayed in the workspace agent bar.
Workflow:
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs. For `/icon/` paths, look at what similar modules already use.
2. **Use existing icons when they fit.** If the tool already has an icon in `.icons/` and `/icon/`, use those.
3. **When an icon doesn't exist,** reference the expected path anyway (e.g. `../../../../.icons/my-tool.svg` and `/icon/my-tool.svg`) so the structure is correct. Try to source the official SVG from the tool's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
4. **Don't substitute a generic icon.** If the tool has its own brand identity, use the correct name even if the file doesn't exist yet. Don't fall back to generic icons like `coder.svg` or `terminal.svg`.
5. **Track missing icons** so you can report them in your response.
## Scripts
Modules use three patterns for shell logic, depending on complexity:
### Root `run.sh` + `templatefile()` (simple modules)
A single `run.sh` at the module root, loaded via `templatefile()` to inject Terraform variables. Used by `code-server`, `vscode-web`, `git-clone`, `dotfiles`, `filebrowser`.
```tf
resource "coder_script" "my_tool" {
agent_id = var.agent_id
display_name = "My Tool"
icon = "/icon/my-tool.svg"
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
})
run_on_start = true
}
```
Use `$${VAR}` (double dollar) in the shell script for Terraform `templatefile` escaping.
If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` statement must come before `set -u`; CI enforces this ordering.
### `scripts/` directory + `file()` (complex modules)
Separate `scripts/install.sh` and `scripts/start.sh` loaded via `file()` into `locals`, then passed to a child module or encoded inline. Used by `coder/claude-code`, `coder-labs/copilot`, `coder-labs/codex`, `coder-labs/cursor-cli`, `coder/amazon-q` for example.
```tf
locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
}
```
Use `file()` when scripts don't need Terraform variable interpolation. For config templates, use a `templates/` directory with `templatefile()` (e.g. `coder/amazon-q/templates/agent-config.json.tpl`).
### Inline heredoc (minimal modules)
For trivial logic, embed the script directly in the `coder_script` resource. Used by `cursor`, `zed`.
Modules that use a `scripts/` directory often also have a `testdata/` directory containing mock scripts for testing (e.g. `testdata/my-tool-mock.sh`).
## Testing
### .tftest.hcl (Required)
Every module must have Terraform native tests. The file can be named `main.tftest.hcl` or `<module-name>.tftest.hcl`. Use `command = plan` for most cases:
```hcl
run "plan_with_defaults" {
command = plan
variables {
agent_id = "test-agent-id"
}
assert {
condition = var.agent_id == "test-agent-id"
error_message = "agent_id should be set"
}
}
run "custom_port" {
command = plan
variables {
agent_id = "test-agent-id"
port = 8080
}
assert {
condition = resource.coder_app.my_tool.url == "http://localhost:8080"
error_message = "App URL should use configured port"
}
}
```
Advanced patterns:
- `override_data` to mock data sources like `coder_workspace` and `coder_workspace_owner`
- `command = apply` when testing outputs or computed values
- `expect_failures` to test validation rules
- `regexall()` / `startswith()` / `endswith()` for string assertions
- Assert on `coder_env`, `coder_script`, `coder_app` resource attributes
```hcl
run "with_mocked_workspace" {
command = apply
variables {
agent_id = "foo"
}
override_data {
target = data.coder_workspace.me
values = {
name = "test-workspace"
}
}
assert {
condition = output.url == "expected-value"
error_message = "URL should match expected format"
}
}
run "validation_rejects_conflict" {
command = plan
variables {
agent_id = "test"
option_a = true
option_b = true
}
expect_failures = [
var.option_a,
]
}
```
### main.test.ts (Optional)
For more complex testing (Docker containers, script execution, HTTP mocking).
Import from `~test` (mapped to `test/test.ts` via `tsconfig.json`):
```typescript
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
findResourceInstance,
} from "~test";
describe("my-tool", () => {
it("should init successfully", async () => {
await runTerraformInit(import.meta.dir);
});
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent",
});
it("should apply with defaults", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
});
const app = findResourceInstance(state, "coder_app");
expect(app.slug).toBe("my-tool");
expect(app.display_name).toBe("My Tool");
});
});
```
### Test utility API (`~test`)
**Terraform helpers:**
- `runTerraformInit(dir)`: runs `terraform init`.
- `runTerraformApply(dir, vars, customEnv?)`: runs `terraform apply` with a random state file and returns `TerraformState`. Variables are passed as `TF_VAR_*`. Safe to run in parallel. `TerraformState` has `outputs: Record<string, TerraformOutput>` and `resources: TerraformStateResource[]`.
- `testRequiredVariables(dir, vars)`: auto-generates test cases (one success with all vars, plus one per var verifying apply fails without it). Pass `{}` if there are no required vars.
- `findResourceInstance(state, type, name?)`: finds the first resource instance by type. Throws if not found. Optionally filters by name.
**Docker helpers** (require `--network host`, Linux/Colima/OrbStack):
- `runContainer(image, init?)`: starts a detached container and returns its ID. Labeled `modules-test=true` for auto-cleanup.
- `removeContainer(id)`: force-removes a container.
- `execContainer(id, cmd[], args?[])`: runs a command in a container and returns `{ exitCode, stdout, stderr }`.
- `executeScriptInContainer(state, image, shell?, before?)`: finds `coder_script` in state, runs it in a container, and returns `{ exitCode, stdout: string[], stderr: string[] }`.
**File helpers:**
- `writeCoder(id, script)`: writes a mock `coder` CLI to `/usr/bin/coder` in the container.
- `writeFileContainer(id, path, content, { user? })`: writes a file to the container via base64.
- `readFileContainer(id, path)`: reads a file from the container as root.
**HTTP helpers:**
- `createJSONResponse(obj, statusCode?)`: creates a `Response` with a JSON body (defaults to 200).
Cleanup of `*.tfstate` files and `modules-test` Docker containers is handled automatically by `setup.ts` (preloaded via `bunfig.toml`).
## Commands
| Task | Command | Scope |
| ---------------- | ----------------------------------------------------- | ---------- |
| Format all | `bun run fmt` | Repo |
| Terraform tests | `bun run tftest` | Repo |
| TypeScript tests | `bun run tstest` | Repo |
| Single TF test | `terraform init -upgrade && terraform test -verbose` | Module dir |
| Single TS test | `bun test main.test.ts` | Module dir |
| Validate | `./scripts/terraform_validate.sh` | Repo |
| ShellCheck | `bun run shellcheck` | Repo |
| Version bump | `.github/scripts/version-bump.sh patch\|minor\|major` | Repo |
## Version Management
Bump version via `.github/scripts/version-bump.sh` when modifying modules:
- `patch`: bugfixes
- `minor`: new features, new variables with defaults
- `major`: breaking changes (removed inputs, changed defaults, new required variables)
The script automatically updates `version` references in README usage examples.
## Final Checks
Before considering the work complete, verify:
- Tests pass: `bun run tftest` and `bun run tstest`
- `bun run fmt` has been run
- `bun run shellcheck` passes if the module includes shell scripts
- New variables have sensible defaults for backward compatibility
- Breaking changes are documented if any inputs were removed, defaults changed, or new required variables added
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures)
- No hardcoded values that should be configurable via variables
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
## Response to the User
In your response, include:
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
- A note that to contribute the module to the public registry, they can open a pull request to <https://github.com/coder/registry>.
-321
View File
@@ -1,321 +0,0 @@
---
name: coder-templates
description: Creates and updates Coder Registry workspace templates with agent setup, infrastructure provisioning, and module consumption
---
# Coder Templates
Coder workspace templates are complete workspace definitions that live under `registry/<namespace>/templates/<name>/` and provision the infrastructure that workspaces run on.
## Before You Start
Before writing or modifying any code:
1. **Understand the request.** What platform is the template targeting (Docker, AWS, GCP, Azure, Kubernetes)? What kind of workspace (VM, container, devcontainer)?
2. **Research existing templates and modules.** Look under `registry/` in this repo for similar templates and modules first; if you are not in the repo or cannot find a match, browse <https://registry.coder.com>. Read `main.tf` to understand patterns for that platform, especially how they handle agent setup, persistent storage, and module consumption. Prefer platform-specific helper modules (e.g. region selectors) that provide ready-made `coder_parameter` blocks over hard-coding option lists.
3. **Check provider docs.** Verify the infrastructure provider resources you plan to use. Check both the Coder provider and the platform provider (AWS, Docker, etc.) version-specific docs if needed.
4. **Clarify before building.** If the request is ambiguous (e.g. unclear platform, whether to use devcontainers vs plain VMs, what parameters to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
5. **Plan the structure.** Decide on infrastructure resources, what `coder_parameter` options to expose, which registry modules to consume, and whether additional files like cloud-init configs are needed. When the user describes requirements in terms of their development needs rather than specific Terraform changes (e.g. "I need Node 20 + Postgres 16" or "make this template work for data science"), summarize what you plan to add or change before proceeding. Keep it brief: list the parameters, modules, and infrastructure changes. Skip this for straightforward requests where the action is clear (e.g. "add the code-server module" or "change the default region to us-west-2").
When updating an existing template, read and understand all of its current resources, parameters, and module consumption before making changes. If you observe patterns that deviate from the coder template standards (e.g. missing metadata blocks, hardcoded values that should be parameters, inline implementations that existing modules could replace, missing error handling in scripts), note these to the user as improvement opportunities in your response.
Always prefer the proper implementation over a simpler shortcut. Templates are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the template incomplete or fragile.
Features marked as "Premium" in this skill require a Coder Premium license. When your implementation uses a Premium feature, note this in your response to the user so they can verify their deployment supports it.
## Documentation References
### Coder
- Platform docs (latest): <https://coder.com/docs>
- Version-specific docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
- Creating templates: <https://coder.com/docs/admin/templates/creating-templates>
- Extending templates: <https://coder.com/docs/admin/templates/extending-templates>
- Template parameters: <https://coder.com/docs/admin/templates/extending-templates/parameters>
- Dynamic parameters: <https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters>
- Workspace presets: <https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets>
- Prebuilt workspaces: <https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces>
- Tasks: <https://coder.com/docs/ai-coder/tasks>
- Agent Boundaries: <https://coder.com/docs/ai-coder/agent-boundaries>
- Coder Registry: <https://registry.coder.com>
### Coder Terraform provider
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
Resources:
| Resource | Docs |
| ---------------- | ------------------------------------------------------------------------------------ |
| `coder_agent` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/agent> |
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
| `coder_ai_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/ai_task> |
Data sources:
| Data Source | Docs |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
| `coder_provisioner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/provisioner> |
| `coder_workspace_preset` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_preset> |
| `coder_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/task> |
### Terraform providers commonly used in templates
All provider docs follow `https://registry.terraform.io/providers/ORG/NAME/latest/docs`:
| Provider | Source |
| ---------- | ---------------------- |
| Docker | `kreuzwerker/docker` |
| AWS | `hashicorp/aws` |
| Azure | `hashicorp/azurerm` |
| GCP | `hashicorp/google` |
| Kubernetes | `hashicorp/kubernetes` |
| Cloud-Init | `hashicorp/cloudinit` |
Browse all providers: <https://registry.terraform.io/browse/providers>
## Scaffolding a New Template
Only use this when creating a brand new template that does not yet exist. When updating an existing template, edit its files directly.
From repo root:
```bash
./scripts/new_template.sh namespace/template-name
```
Names must be lowercase alphanumeric with hyphens (e.g. `my-org/aws-ec2`). Underscores are not allowed.
Creates `registry/<namespace>/templates/<template-name>/` with:
- `main.tf`: full workspace Terraform config with common patterns — read this as the primary reference for template structure
- `README.md`: frontmatter and documentation
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
## Key Patterns
- Provider version constraints must reflect actual functionality requirements. Only set a minimum `coder` provider version when the template uses a resource, attribute, or behavior introduced in that version. The same applies to infrastructure providers (Docker, AWS, etc.); check provider changelogs to confirm.
- Include `data.coder_workspace.me` and `data.coder_workspace_owner.me` for workspace and owner metadata. Include `data.coder_provisioner.me` only when you need the provisioner's `arch` or `os` for `coder_agent` (typical for Docker, Kubernetes, Incus); omit when the workspace OS/arch is fixed (e.g. cloud VMs with a known image).
- Use `locals {}` for computed values: username, environment variables, startup scripts, URL assembly
- Use `data.coder_workspace.me.start_count` as `count` on ephemeral resources
- Connect containers/VMs to the agent via `coder_agent.main.init_script` and `CODER_AGENT_TOKEN`
- Add `metadata` blocks for workspace dashboard stats (`coder stat cpu`, `coder stat mem`, etc.)
- Use `coder_metadata` on the primary compute resource to surface key details (region, instance type, image, disk size) in the workspace dashboard
- Optionally use `display_apps` block to hide specific built-in apps (defaults show all)
- Before implementing functionality from scratch, look for an existing module under `registry/*/modules/` in this repo; if you cannot find one or are not in the repo, search <https://registry.coder.com>. If a module already exists for what you need, consume it rather than reimplementing it. When multiple modules serve similar purposes, prefer the actively maintained one and check that you are not using a deprecated or superseded module.
- Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. Prefer paths under `registry/<namespace>/modules/<name>/` in this workspace; otherwise use `https://registry.coder.com/modules/<namespace>/<module-name>`. Never pass arguments without confirming they exist.
- After identifying a module's prerequisites, verify the template's base image satisfies them. If it lacks a required tool, either switch to an image that includes it or ensure the prerequisite is installed before the module's script runs. These runtime issues are not caught by `terraform validate`; they only surface when the workspace starts.
- Module source URLs use `registry.coder.com/<namespace>/<module>/coder`. Older templates may use `registry.coder.com/modules/...`; prefer the shorter form when writing new modules or templates.
- Label infrastructure resources with `coder.owner` and `coder.workspace_id` for tracking orphans
- Use `lifecycle { ignore_changes = all }` on persistent volumes to prevent data loss
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
### Additional files
Templates can include files beyond `main.tf` + `README.md`:
- `cloud-init/*.tftpl`: cloud-init configs for VM provisioning (AWS, Azure, GCP), loaded via `templatefile()`. Prefer this subdirectory over placing cloud-init files at the template root.
- `build/Dockerfile`: custom container images built by the template
- `.tftpl` files: any Terraform template files for scripts, configs, or cloud-init data
### Parameters
Use `data "coder_parameter"` for user-facing workspace options. Typical parameters: region/instance type/CPU/memory/disk for cloud VMs; container image or runtime version for Docker (pass as `build_arg` when using a local Dockerfile). Use same-platform templates in `registry/` as a starting reference, not a rigid pattern. Expose stated preferences as the parameter `default` with additional sensible `option` values unless the user explicitly restricts it.
- Prefer `dynamic "option"` blocks with `for_each` from a `locals` map over static `option` blocks. See the region selector modules (e.g. `coder/aws-region`) for the pattern.
- Use `form_type` for richer UI controls: `dropdown` (searchable), `multi-select` (for `list(string)`), `slider` (numeric), `radio`, `checkbox`, `textarea`.
- Conditional parameters: use `count` to show/hide a parameter based on another parameter's value.
- `mutable = false` for infrastructure that can't change after creation (region, disk); `mutable = true` for runtime config.
- `ephemeral = true` for one-shot build options that don't persist between starts.
- `validation {}` with `min`/`max`/`monotonic` for numbers, `regex`/`error` for strings.
- Dynamic parameter features require Coder provider `>= 2.4.0`.
### Presets
Workspace presets bundle commonly-used parameter combinations into selectable options. When a user creates a workspace, they can pick a preset to auto-fill multiple parameters at once. Define presets with `data "coder_workspace_preset"`:
```tf
data "coder_workspace_preset" "default" {
name = "Standard Dev Environment"
default = true
parameters = {
"region" = "us-east-1"
"cpu" = "4"
"memory" = "8"
"container_image" = "codercom/enterprise-base:ubuntu"
}
}
```
- The keys in `parameters` must match the `name` attribute of `coder_parameter` data sources in the same template.
- Set `default = true` on at most one preset to pre-select it in the UI.
- A template can define multiple presets for different use cases.
- Optional fields: `description` (context text in UI) and `icon` (e.g. `/emojis/1f680.png`).
### Prebuilds (Premium)
Prebuilds maintain an automatically-managed pool of pre-provisioned workspaces for a preset, reducing workspace creation time. This is a Premium feature. Prebuilds are configured as a nested block inside a preset:
```tf
data "coder_workspace_preset" "goland" {
name = "GoLand: Large"
parameters = {
"jetbrains_ide" = "GO"
"cpu" = "8"
"memory" = "16"
}
prebuilds {
instances = 3
expiration_policy {
ttl = 86400
}
scheduling {
timezone = "UTC"
schedule {
cron = "* 8-18 * * 1-5"
instances = 5
}
}
}
}
```
- `instances`: number of prebuilt workspaces to keep in the pool (base count when no schedule matches).
- `expiration_policy.ttl`: seconds before unclaimed prebuilds are cleaned up.
- `scheduling`: scale the pool up or down on a time-based cron schedule. The `cron` minute field must always be `*`.
- The preset must define all required parameters needed to build the workspace.
- When a prebuild is claimed, ownership transfers to the real user. Use `lifecycle { ignore_changes = [...] }` on resources that reference owner-specific values to prevent unnecessary recreation.
### Task-Oriented Templates
A template becomes task-capable by adding a `coder_ai_task` resource, which enables the Coder Tasks UI for AI agent workflows. Task templates require three additions on top of a regular template:
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.claude-code[count.index].task_app_id
}
data "coder_task" "me" {}
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "~> 4.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
ai_prompt = data.coder_task.me.prompt
system_prompt = data.coder_parameter.system_prompt.value
model = "sonnet"
permission_mode = "plan"
enable_boundary = true
}
```
- `coder_ai_task`: declares the template as task-capable. Its `app_id` must point to the agent module's `task_app_id` output.
- `data "coder_task"`: reads the user's task prompt. Pass it to the agent module via `ai_prompt`.
- Agent module: consume an AI agent module (`claude-code`, `codex`, etc.) with task-specific variables. Key variables include `ai_prompt`, `system_prompt`, `permission_mode`, and `enable_boundary`.
- Boundaries: set `enable_boundary = true` on the agent module to enable network-level filtering for the AI agent. See <https://coder.com/docs/ai-coder/agent-boundaries> for allowlist configuration.
- A `coder_app` with `slug = "preview"` gets special treatment in the Tasks UI navbar.
- Task templates heavily use presets to define scenarios (different repos, system prompts, setup scripts, container images).
- See `registry/coder-labs/templates/tasks-docker` as a reference implementation.
Docs: <https://coder.com/docs/ai-coder/tasks>
## README.md
Required YAML frontmatter:
```yaml
---
display_name: Docker Containers
description: Provision Docker containers with persistent home volumes as Coder workspaces
icon: ../../../../.icons/docker.svg
verified: false
tags: [docker, container]
---
```
Content rules:
- Single H1 heading matching `display_name`, directly below frontmatter
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
- Opening paragraph describing what the template provisions. Be specific about the platform, compute type, and key capabilities (e.g. "Provision Kubernetes pods on an existing Amazon EKS cluster as Coder workspaces with persistent home volumes") rather than generic (e.g. "AWS Kubernetes template"). The frontmatter `description` field should follow the same principle.
- **Prerequisites** section (infrastructure requirements, provider credentials)
- **Architecture** section (what resources are created, what's ephemeral vs persistent)
- Code fences labeled `tf` (NOT `hcl`)
- Relative icon paths (e.g. `../../../../.icons/`)
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Workspace parameter options are visible in the Coder UI. Describe what the template does and how to use it in prose, not by listing every configurable field.
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
## Icons
Templates reference icons in the README frontmatter `icon:` field using a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/aws.svg`). This icon is displayed on the registry website.
Workflow:
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs.
2. **Use existing icons when they fit.** Most templates use a platform icon (aws, gcp, azure, docker, kubernetes) that already exists.
3. **When an icon doesn't exist,** reference the expected path anyway so the structure is correct. Try to source the official SVG from the platform's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
4. **Don't substitute a generic icon.** If the platform has its own brand identity, use the correct name even if the file doesn't exist yet.
5. **Track missing icons** so you can report them in your response.
## Testing
Templates do NOT require `.tftest.hcl` or `main.test.ts`. Testing is done by pushing the template to a Coder deployment.
## Commands
| Task | Command | Scope |
| ---------- | --------------------------------- | ----- |
| Format all | `bun run fmt` | Repo |
| Validate | `./scripts/terraform_validate.sh` | Repo |
| ShellCheck | `bun run shellcheck` | Repo |
## Final Checks
Before considering the work complete, verify:
- `terraform init && terraform validate` passes in the template directory
- `bun run fmt` has been run
- `bun run shellcheck` passes if the template includes shell scripts
- README documents prerequisites and architecture
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures). If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` must come before `set -u`; CI enforces this ordering.
- No hardcoded values that should be configurable via variables or parameters
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
## Response to the User
In your response, include:
- A ready-to-run push command with real values filled in. Use `-d` to point at the template directory (so it works from the repo root), `-m` for a short description, and `-y` to skip interactive prompts:
```bash
coder templates push \
registry/ \
-m "Initial version: <brief description>" \
-y < template-name > -d < namespace > /templates/ < template-name > /
```
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
- A note that to contribute the template to the public registry, they can open a pull request to <https://github.com/coder/registry>.
-1
View File
@@ -1 +0,0 @@
../.agents/skills
+8 -8
View File
@@ -1,7 +1,7 @@
name: CI
on:
pull_request:
branches: [main]
# Cancel in-progress runs for pull requests when developers push new changes
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,7 +14,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
with:
list-files: shell
@@ -37,9 +37,9 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
# We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases
@@ -82,18 +82,18 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
with:
config: .github/typos.toml
validate-readme-files:
@@ -106,7 +106,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: "1.24.0"
- name: Validate contributors
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: golangci-lint
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
@@ -62,7 +62,7 @@ jobs:
- name: Comment on PR - Version bump required
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
advanced-security: false
annotations: true
@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
inputs: |
.github/workflows
-1
View File
@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>1Password</title><circle cx="12" cy="12" r="12" fill="#0572EC"/><path fill="#fff" d="M11.105 4.864h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"/></svg>

Before

Width:  |  Height:  |  Size: 999 B

+1 -3
View File
@@ -1,3 +1 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8461 2.7571C15.2325 2.22387 16.7675 2.22387 18.1539 2.7571L28.0769 6.57367C29.2355 7.01927 30 8.13239 30 9.3737V22.6265C30 23.8678 29.2355 24.9809 28.0769 25.4265L18.1539 29.2431C16.7675 29.7763 15.2325 29.7763 13.8461 29.2431L3.92306 25.4265C2.76449 24.9809 2 23.8678 2 22.6265V9.3737C2 8.13239 2.76449 7.01927 3.92306 6.57367L13.8461 2.7571ZM9.39418 10.0809C8.88655 9.86331 8.29867 10.0985 8.08111 10.6061C7.86356 11.1137 8.09871 11.7016 8.60634 11.9192L15.0003 14.6594V21C15.0003 21.5523 15.448 22 16.0003 22C16.5525 22 17.0003 21.5523 17.0003 21V14.6594L23.3942 11.9192C23.9018 11.7016 24.137 11.1137 23.9194 10.6061C23.7018 10.0985 23.114 9.86331 22.6063 10.0809L16.0003 12.912L9.39418 10.0809Z" fill="#212121"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 32 32"><title>file_type_devcontainer</title><circle cx="16" cy="16" r="14" style="fill:#193e63"/><polygon points="10.777 22.742 9.343 21.348 12.729 17.865 9.346 14.417 10.774 13.017 15.525 17.859 10.777 22.742" style="fill:#add1ea"/><polygon points="21.42 19.101 22.854 17.706 19.468 14.224 22.851 10.776 21.423 9.376 16.672 14.218 21.42 19.101" style="fill:#add1ea"/></svg>

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 452 B

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
<g fill="#40BE46">
<!-- Eye shape -->
<path d="M100 40C55 40 20 80 10 100c10 20 45 60 90 60s80-40 90-60c-10-20-45-60-90-60zm0 100c-35 0-63-28-75-40 12-12 40-40 75-40s63 28 75 40c-12 12-40 40-75 40z"/>
<!-- Inner circle (magnifying glass lens) -->
<path d="M100 72a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 44a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/>
<!-- Horizontal line below -->
<rect x="25" y="170" width="150" height="12" rx="6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 542 B

+1 -11
View File
@@ -1,11 +1 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_147_2)">
<path d="M162.358 73H257V182H162.358V73Z" fill="white"/>
<path d="M0 182V78.4618H26.039L27.0034 103.381L24.3033 102.221C25.7177 96.6843 27.8391 91.9835 30.6684 88.1202C33.6255 84.2569 37.1618 81.2949 41.2769 79.2343C45.3914 77.1742 49.8921 76.1439 54.7785 76.1439C63.3938 76.1439 70.3377 78.6552 75.6097 83.6773C81.0105 88.6998 84.4824 95.4606 86.0251 103.96L82.3606 104.153C83.518 98.1008 85.5112 93.0138 88.3401 88.8931C91.2976 84.6431 94.8978 81.4883 99.1411 79.4277C103.385 77.2387 108.143 76.1439 113.415 76.1439C120.615 76.1439 126.788 77.6249 131.931 80.5869C137.075 83.5488 141.061 87.9913 143.89 93.9152C146.719 99.7102 148.133 106.858 148.133 115.357V182H119.201V123.47C119.201 115.357 117.98 109.305 115.536 105.312C113.093 101.191 109.107 99.1311 103.577 99.1311C100.106 99.1311 97.1484 100.097 94.7052 102.029C92.262 103.96 90.3332 106.793 88.9188 110.528C87.6326 114.134 86.9895 118.577 86.9895 123.857V182H60.9506V123.857C60.9506 115.872 59.7936 109.755 57.4787 105.505C55.1642 101.256 51.1779 99.1311 45.5202 99.1311C42.0482 99.1311 39.0263 100.097 36.4548 102.029C34.0117 103.96 32.1472 106.793 30.861 110.528C29.5753 114.262 28.9322 118.705 28.9322 123.857V182H0Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_147_2">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="62" fill="none" viewBox="0 0 135 62"><path fill="#fff" d="M3.168 48V22.272H9.648L9.888 28.464L9.216 28.176C9.568 26.8 10.096 25.632 10.8 24.672C11.536 23.712 12.416 22.976 13.44 22.464C14.464 21.952 15.584 21.696 16.8 21.696C18.944 21.696 20.672 22.32 21.984 23.568C23.328 24.816 24.192 26.496 24.576 28.608L23.664 28.656C23.952 27.152 24.448 25.888 25.152 24.864C25.888 23.808 26.784 23.024 27.84 22.512C28.896 21.968 30.08 21.696 31.392 21.696C33.184 21.696 34.72 22.064 36 22.8C37.28 23.536 38.272 24.64 38.976 26.112C39.68 27.552 40.032 29.328 40.032 31.44V48H32.832V33.456C32.832 31.44 32.528 29.936 31.92 28.944C31.312 27.92 30.32 27.408 28.944 27.408C28.08 27.408 27.344 27.648 26.736 28.128C26.128 28.608 25.648 29.312 25.296 30.24C24.976 31.136 24.816 32.24 24.816 33.552V48H18.336V33.552C18.336 31.568 18.048 30.048 17.472 28.992C16.896 27.936 15.904 27.408 14.496 27.408C13.632 27.408 12.88 27.648 12.24 28.128C11.632 28.608 11.168 29.312 10.848 30.24C10.528 31.168 10.368 32.272 10.368 33.552V48H3.168ZM54.2254 48.576C51.5694 48.576 49.4894 47.728 47.9854 46.032C46.5134 44.304 45.7774 41.904 45.7774 38.832V22.272H52.9774V37.152C52.9774 39.136 53.2814 40.592 53.8894 41.52C54.4974 42.416 55.4574 42.864 56.7694 42.864C58.2414 42.864 59.3774 42.368 60.1774 41.376C61.0094 40.352 61.4254 38.832 61.4254 36.816V22.272H68.6254V48H62.0494L61.8574 40.608L62.7694 40.8C62.3854 43.36 61.4734 45.296 60.0334 46.608C58.5934 47.92 56.6574 48.576 54.2254 48.576ZM72.8486 48L82.0166 34.944L73.0886 22.272H80.7206L86.2406 30.528L91.5686 22.272H99.3926L90.5126 34.992L99.6326 48H92.0006L86.3366 39.264L80.6246 48H72.8486Z"/><rect width="26" height="35" x="109" y="13" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.3333 213.333C33.0666 213.333 29.3333 211.733 26.1333 208.533C22.9333 205.333 21.3333 201.6 21.3333 197.333V58.6667C21.3333 54.4001 22.9333 50.6667 26.1333 47.4667C29.3333 44.2667 33.0666 42.6667 37.3333 42.6667H218.667C222.933 42.6667 226.667 44.2667 229.867 47.4667C233.067 50.6667 234.667 54.4001 234.667 58.6667V197.333C234.667 201.6 233.067 205.333 229.867 208.533C226.667 211.733 222.933 213.333 218.667 213.333H37.3333ZM37.3333 197.333H218.667V81.0668H37.3333V197.333ZM80 178.133L68.8 166.933L96.2666 139.2L68.5333 111.467L80 100.267L118.933 139.2L80 178.133ZM130.667 179.2V163.2H189.333V179.2H130.667Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

-315
View File
@@ -1,315 +0,0 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="display: block;" viewBox="0 0 1024 1024" width="256" height="256" preserveAspectRatio="none">
<defs>
<linearGradient id="Gradient1" gradientUnits="userSpaceOnUse" x1="874.313" y1="395.088" x2="877.337" y2="360.881">
<stop class="stop0" offset="0" stop-opacity="1" stop-color="rgb(1,33,54)"/>
<stop class="stop1" offset="1" stop-opacity="1" stop-color="rgb(1,57,89)"/>
</linearGradient>
<linearGradient id="Gradient2" gradientUnits="userSpaceOnUse" x1="744.28" y1="621.189" x2="708.717" y2="616.83">
<stop class="stop0" offset="0" stop-opacity="1" stop-color="rgb(0,53,88)"/>
<stop class="stop1" offset="1" stop-opacity="1" stop-color="rgb(0,87,137)"/>
</linearGradient>
<linearGradient id="Gradient3" gradientUnits="userSpaceOnUse" x1="739.707" y1="449.293" x2="765.194" y2="467.905">
<stop class="stop0" offset="0" stop-opacity="1" stop-color="rgb(3,94,149)"/>
<stop class="stop1" offset="1" stop-opacity="1" stop-color="rgb(3,62,91)"/>
</linearGradient>
</defs>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 480.311 0 L 547.672 0 C 559.823 7.38132 590.16 6.95228 605.152 9.80657 C 657.988 19.8659 704.471 36.0789 752.206 60.6853 C 836.033 103.34 906.068 168.862 954.203 249.668 C 958.297 256.683 961.17 264.531 965.001 271.395 C 984.511 306.349 998.339 342.996 1009.47 381.349 C 1013.02 393.608 1016.15 413.384 1018.08 426.313 C 1020.03 439.384 1020.54 460.603 1024 473.136 L 1024 511.483 L 1024 519.716 L 1024 553.665 L 1023.87 554.102 C 1019.62 569.195 1020.32 590.981 1017.58 606.93 C 982.095 813.191 818.358 978.648 612.363 1016.91 C 601.889 1018.86 569.435 1020.33 562.61 1024 L 465.443 1024 C 458.328 1020.29 427.033 1019.03 417.47 1017.06 C 404.321 1014.35 390.649 1011.02 377.646 1007.5 C 209.798 962.674 76.8154 834.651 25.6486 668.627 C 19.6735 649.304 14.3836 629.632 9.89543 609.903 C 6.71127 595.907 7.86466 565.295 0 552.99 L 0 477.369 C 6.49032 468.327 7.41519 431.393 10.1274 419.367 C 13.5703 404.102 17.5016 386.201 21.7975 371.238 C 56.8755 246.972 138.048 140.774 248.752 74.3132 C 301.125 43.577 358.578 22.6425 417.737 10.9292 C 431.708 8.16306 469.341 6.47511 480.311 0 z M 788.594 426.829 C 786.868 429.076 786.544 429.441 785.738 432.14 C 788.952 432.292 792.147 432.934 794.618 431.249 C 796.824 426.454 797.522 426.978 802.12 425.571 L 796.531 425.808 C 792.927 426.934 792.349 426.926 788.594 426.829 z M 741.364 580.274 C 745.23 581.555 747.059 580.888 751.11 579.26 L 751.184 576.808 C 748.649 575.033 745.829 574.876 742.751 574.391 L 741.364 580.274 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 1024 519.716 C 1018.82 520.441 1018.33 520.979 1015.01 516.891 C 1014.82 513.311 1014.42 514.679 1016.52 511.751 C 1019.5 510.205 1020.79 510.78 1024 511.483 L 1024 519.716 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 487.252 175.771 C 490.22 175.872 503.898 178.805 507.646 179.545 C 523.627 182.7 534.717 179.295 549.972 175.609 C 558.748 200.258 577.265 227.722 586.928 252.672 C 588.122 255.753 589.681 258.444 593.046 259.093 C 597.554 254.021 604.398 240.68 608.294 234.143 C 619.644 215.103 630.208 194.896 641.703 175.996 C 657.378 183.754 661.439 181.933 677.079 175.397 C 670.698 182.758 662.833 194.921 657.142 203.24 C 646.31 218.812 635.814 234.614 625.661 250.637 C 619.702 260.141 610.45 275.977 603.528 284.171 C 609.313 291.555 621.482 315.364 626.538 324.703 L 673.158 410.876 C 673.509 411.532 673.424 412.305 673.477 413.174 C 670.164 414.572 623.792 414.101 616.134 414.201 C 614.479 410.031 612.598 405.996 610.52 402.023 C 597.828 377.761 585.872 353.025 572.318 329.231 C 565.534 337.818 556.876 352.769 551.044 362.661 C 541.258 379.259 530.372 397.156 521.384 414.128 C 509.664 413.812 497.55 414.006 485.798 414.045 C 499.268 394.193 512.773 375.257 526.828 355.806 C 538.136 340.158 548.786 321.69 560.714 306.676 C 552.114 298.197 531.01 251.337 522.914 237.815 C 511.062 218.02 498.393 196.029 487.252 175.771 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 772.854 292.124 C 772.691 292.219 779.047 286.294 781.6 284.515 L 785.798 289.684 L 787.962 289.398 C 790.689 288.939 791.306 288.463 793.718 287.066 C 795.983 287.265 801.296 285.123 806.247 284.844 C 808.393 285.147 811.083 285.612 813.2 285.662 C 816.586 286.243 820.069 286.359 822.359 288.739 C 826.559 289.002 831.743 288.314 834.705 290.286 L 836.449 292.703 C 837.738 295.248 838.279 296.077 840.616 297.724 L 844.412 297.899 C 847.944 303.297 845.508 307.69 846.286 313.374 L 847.596 314.978 C 847.234 318.199 846.53 319.175 843.659 321.265 C 840.444 322.425 828.924 324.951 828.051 326.09 L 828.407 327.657 C 832.19 329.496 832.677 328.899 833.982 331.889 C 833.133 334.639 832.145 337.329 831.877 340.176 C 833.492 341.138 835.404 342.552 837.119 342.81 L 834.071 347.843 C 835.792 350.055 836.816 351.666 839.119 353.295 L 844.579 350.501 C 847.475 357.751 847.748 365.984 852.482 370.768 C 851.193 371.723 848.839 373.158 848.262 374.416 L 846.16 378.431 L 847.902 381.876 L 846.437 382.28 C 843.752 384.625 838.289 390.565 836.263 392.095 C 832.005 393.453 824.491 394.668 823.258 396.045 L 823.166 396.472 L 820.527 401.702 C 816.15 400.24 816.322 399.495 813.911 396.021 C 811.677 396.936 811.585 396.924 809.138 396.833 L 801.981 395.094 C 798.466 392.427 794.256 391.578 790.019 391.061 C 788.813 393.631 788.357 395.434 785.961 396.805 L 783.691 398.576 C 784.444 405.5 790.123 407.398 792.204 411.332 C 795.85 415.118 801.576 420.462 804.089 424.817 L 803.569 426.176 L 802.12 425.571 L 796.531 425.808 C 792.927 426.934 792.349 426.926 788.594 426.829 C 786.868 429.076 786.544 429.441 785.738 432.14 L 782.631 432.771 C 781.253 430.954 779.84 428.546 778.609 426.578 C 776.531 427.813 776.061 427.895 774.918 429.914 L 775.204 431.185 C 773.848 432.524 772.894 433.793 771.105 434.293 C 762.451 431.8 761.059 431.43 752.996 427.512 L 750.717 426.623 C 746.877 426.724 742.355 426.377 739.299 428.495 L 738.734 429.537 C 739.633 431.228 740.333 432.395 741.384 433.996 L 740.373 435.33 C 732.076 435.814 731.343 434.103 727.034 441.246 C 722.605 440.978 717.949 440.218 713.752 440.096 L 714.501 438.9 C 716.382 436.087 714.07 436.256 716.355 433.409 L 723.12 432.816 C 726.727 432.439 731.674 432.424 735.397 432.3 C 735.254 425.111 733.982 425.369 727.654 421.893 C 716.103 422.32 698.91 425.54 691.422 415.596 C 689.103 414.236 688.123 411.803 686.74 409.365 C 681.16 402.269 675.023 396.41 670.963 388.102 C 669.832 382.406 668.093 382.226 667.058 377.768 C 677.035 374.316 678.559 371.351 686.035 364.749 C 689.668 362.014 700.149 355.787 704.501 352.941 C 707.364 349.844 710.118 347.89 713.44 345.369 L 715.195 343.605 L 720.915 342.17 L 721.599 336.254 C 719.089 335.344 718.773 335.214 717.25 333.039 C 719.412 325.313 725.013 322.031 725.461 314.757 C 721.087 311.09 716.892 310.284 719.583 302.296 C 722.831 299.32 721.453 300.162 725.844 299.259 C 743.252 300.517 742.03 305.303 757.68 296.248 C 762.125 294.244 763.006 294.228 767.933 294.2 L 772.854 292.124 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 772.854 292.124 C 772.691 292.219 779.047 286.294 781.6 284.515 L 785.798 289.684 L 787.962 289.398 C 790.689 288.939 791.306 288.463 793.718 287.066 C 795.983 287.265 801.296 285.123 806.247 284.844 C 808.393 285.147 811.083 285.612 813.2 285.662 C 816.586 286.243 820.069 286.359 822.359 288.739 C 826.559 289.002 831.743 288.314 834.705 290.286 L 836.449 292.703 C 837.738 295.248 838.279 296.077 840.616 297.724 L 844.412 297.899 C 847.944 303.297 845.508 307.69 846.286 313.374 L 847.596 314.978 C 847.234 318.199 846.53 319.175 843.659 321.265 C 840.444 322.425 828.924 324.951 828.051 326.09 L 828.407 327.657 L 822.154 331.796 L 820.952 326.805 C 818.362 328.409 815.822 329.796 813.634 331.899 C 811.089 328.866 811.762 330.343 811.291 326.174 L 810.077 322.833 L 810.339 322.087 C 808.152 319.579 806.751 316.322 805.253 313.321 L 800.987 311.985 C 797.474 311.544 798.684 312.014 796.013 308.679 L 792.986 308.611 L 794.11 303.651 L 792.056 303.883 C 788.924 303.207 777.421 302.21 773.607 301.775 L 767.642 298.361 L 767.933 294.2 L 772.854 292.124 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 822.359 288.739 C 826.559 289.002 831.743 288.314 834.705 290.286 L 836.449 292.703 C 837.738 295.248 838.279 296.077 840.616 297.724 L 844.412 297.899 C 847.944 303.297 845.508 307.69 846.286 313.374 L 847.596 314.978 C 847.234 318.199 846.53 319.175 843.659 321.265 L 839.94 318.602 C 837.085 316.898 828.427 310.694 826.877 310.401 C 827.583 309.79 828.533 308.623 829.183 307.884 C 829.318 304.855 826.409 302.873 825.903 297.876 C 824.066 294.651 821.837 295.247 818.716 295.226 C 822.208 292.739 821.169 293.722 822.359 288.739 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 836.449 292.703 C 837.738 295.248 838.279 296.077 840.616 297.724 L 844.412 297.899 C 847.944 303.297 845.508 307.69 846.286 313.374 C 840.31 310.971 842.537 309.666 837.946 306.645 C 833.736 309.031 834.07 309.397 829.822 307.09 L 829.337 304.968 C 831.146 302.49 830.877 303.216 834.729 302.412 C 837.433 297.887 832.274 300.811 836.449 292.703 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 772.854 292.124 C 772.691 292.219 779.047 286.294 781.6 284.515 L 785.798 289.684 L 787.962 289.398 C 793.807 290.518 794.398 292.025 798.205 296.835 L 799.334 296.624 C 800.723 300.66 801.658 302.412 800.129 306.481 C 797.186 307.106 796.14 305.882 794.11 303.651 L 792.056 303.883 C 788.924 303.207 777.421 302.21 773.607 301.775 L 767.642 298.361 L 767.933 294.2 L 772.854 292.124 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 772.854 292.124 C 772.691 292.219 779.047 286.294 781.6 284.515 L 785.798 289.684 L 785.898 296.953 L 782.402 299.317 C 776.692 299.259 781.079 294.912 772.854 292.124 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 785.798 289.684 L 787.962 289.398 C 793.807 290.518 794.398 292.025 798.205 296.835 C 797.556 299.017 797.395 299.246 795.841 300.914 C 792.288 301.24 789.664 298.899 785.898 296.953 L 785.798 289.684 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 819.681 317.909 L 820.596 316.55 C 824.879 320.818 835.428 321.73 839.94 318.602 L 843.659 321.265 C 840.444 322.425 828.924 324.951 828.051 326.09 L 828.407 327.657 L 822.154 331.796 L 820.952 326.805 C 821.251 322.845 821.848 321.252 819.681 317.909 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 819.681 317.909 C 821.848 321.252 821.251 322.845 820.952 326.805 C 818.362 328.409 815.822 329.796 813.634 331.899 C 811.089 328.866 811.762 330.343 811.291 326.174 L 810.077 322.833 L 810.339 322.087 L 813.815 318.865 L 816.355 321.349 L 819.681 317.909 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 813.815 318.865 L 816.355 321.349 L 811.291 326.174 L 810.077 322.833 L 810.339 322.087 L 813.815 318.865 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 793.718 287.066 C 795.983 287.265 801.296 285.123 806.247 284.844 L 799.334 296.624 L 798.205 296.835 C 794.398 292.025 793.807 290.518 787.962 289.398 C 790.689 288.939 791.306 288.463 793.718 287.066 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 813.815 318.865 C 814.658 315.117 809.775 307.807 812.42 304.129 C 815.386 306.11 819.906 313.183 820.596 316.55 L 819.681 317.909 L 816.355 321.349 L 813.815 318.865 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 813.2 285.662 C 816.586 286.243 820.069 286.359 822.359 288.739 C 821.169 293.722 822.208 292.739 818.716 295.226 C 817.172 294.591 815.508 293.761 813.984 293.045 C 812.212 289.501 812.815 290.18 813.2 285.662 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 825.903 297.876 C 826.409 302.873 829.318 304.855 829.183 307.884 C 828.533 308.623 827.583 309.79 826.877 310.401 C 824.341 309.653 823.051 309.576 821.447 307.525 C 821.518 304.287 824.015 301.567 825.903 297.876 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 757.68 296.248 C 762.125 294.244 763.006 294.228 767.933 294.2 L 767.642 298.361 L 773.607 301.775 C 777.421 302.21 788.924 303.207 792.056 303.883 L 794.11 303.651 L 792.986 308.611 L 791.668 309.098 C 787.051 309.16 785.596 309.331 781.308 307.791 L 780.021 307.601 C 779.432 309.668 777.76 314.538 777.965 316.267 C 775.289 318.664 774.171 319.878 770.847 321.301 L 770.163 321.382 C 767.169 323.918 768.561 323.469 764.995 323.309 C 762.139 321.347 759.851 319.183 756.528 319.594 C 748.907 322.689 746.865 330.07 739.78 335.953 C 739.484 334.213 738.593 332.985 737.703 331.444 C 740.167 328.73 741.705 327.564 742.368 323.959 C 741.221 321.158 742.05 322.121 738.759 320.55 L 735.68 319.471 C 730.085 318.315 728.613 321.049 725.905 314.872 L 725.461 314.757 C 721.087 311.09 716.892 310.284 719.583 302.296 C 722.831 299.32 721.453 300.162 725.844 299.259 C 743.252 300.517 742.03 305.303 757.68 296.248 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 757.68 296.248 C 762.125 294.244 763.006 294.228 767.933 294.2 L 767.642 298.361 L 773.607 301.775 C 772.18 302.8 770.472 303.582 770.17 304.947 C 767.883 301.496 762.554 303.271 759.099 304.078 L 757.603 302.139 C 752.85 302.522 749.476 304.037 747.255 308.481 C 746.163 310.278 745.324 311.87 743.872 313.395 L 743.615 318.26 L 742.444 320.046 L 738.759 320.55 L 735.68 319.471 C 730.085 318.315 728.613 321.049 725.905 314.872 L 725.461 314.757 C 721.087 311.09 716.892 310.284 719.583 302.296 C 722.831 299.32 721.453 300.162 725.844 299.259 C 743.252 300.517 742.03 305.303 757.68 296.248 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 725.905 314.872 L 726.572 311.824 C 729.365 309.124 729.07 309.947 733.835 309.428 L 736.565 310.561 C 739.955 312.838 741.265 311.3 743.872 313.395 L 743.615 318.26 L 742.444 320.046 L 738.759 320.55 L 735.68 319.471 C 730.085 318.315 728.613 321.049 725.905 314.872 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 725.461 314.757 C 721.087 311.09 716.892 310.284 719.583 302.296 C 722.831 299.32 721.453 300.162 725.844 299.259 L 728.315 302.509 C 725.399 304.652 725.568 303.842 724.658 306.764 C 727.421 309.112 729.993 308.96 733.835 309.428 C 729.07 309.947 729.365 309.124 726.572 311.824 L 725.905 314.872 L 725.461 314.757 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 728.315 302.509 C 733.283 303.102 737.765 302.796 741.201 305.627 C 741.299 308.557 741.612 307.425 740.002 309.9 L 736.565 310.561 L 733.835 309.428 C 729.993 308.96 727.421 309.112 724.658 306.764 C 725.568 303.842 725.399 304.652 728.315 302.509 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 757.68 296.248 C 762.125 294.244 763.006 294.228 767.933 294.2 L 767.642 298.361 C 762.656 299.199 761.593 299.037 757.68 296.248 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 773.607 301.775 C 777.421 302.21 788.924 303.207 792.056 303.883 L 794.11 303.651 L 792.986 308.611 L 791.668 309.098 C 787.051 309.16 785.596 309.331 781.308 307.791 L 780.021 307.601 C 779.432 309.668 777.76 314.538 777.965 316.267 C 775.289 318.664 774.171 319.878 770.847 321.301 L 770.163 321.382 L 768.071 318.667 C 765.936 320.459 766.947 320.145 764.418 319.993 C 759.59 314.643 766.4 308.845 770.17 304.947 C 770.472 303.582 772.18 302.8 773.607 301.775 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 780.021 307.601 C 779.432 309.668 777.76 314.538 777.965 316.267 C 775.289 318.664 774.171 319.878 770.847 321.301 L 770.163 321.382 L 768.071 318.667 C 770.665 314.556 775.502 309.436 780.021 307.601 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 792.056 303.883 L 794.11 303.651 L 792.986 308.611 L 791.668 309.098 C 787.051 309.16 785.596 309.331 781.308 307.791 L 782.366 305.744 C 785.618 303.626 787.816 304.157 792.056 303.883 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 747.255 308.481 C 749.476 304.037 752.85 302.522 757.603 302.139 L 759.099 304.078 C 757.394 306.91 755.685 310.201 753.278 312.398 C 751.318 311.261 749.17 309.751 747.255 308.481 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 747.255 308.481 C 749.17 309.751 751.318 311.261 753.278 312.398 C 751.095 315.331 749.017 318.658 745.851 320.373 L 744.276 319.869 L 743.615 318.26 L 743.872 313.395 C 745.324 311.87 746.163 310.278 747.255 308.481 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 780.021 307.601 L 781.308 307.791 C 785.596 309.331 787.051 309.16 791.668 309.098 L 792.986 308.611 L 796.013 308.679 C 798.684 312.014 797.474 311.544 800.987 311.985 L 805.253 313.321 C 806.751 316.322 808.152 319.579 810.339 322.087 L 810.077 322.833 C 802.055 328.056 800.491 334.225 793.933 337.283 C 790.155 334.168 787.969 335.809 786.324 334.353 C 782.661 331.111 781.114 319.532 777.965 316.267 C 777.76 314.538 779.432 309.668 780.021 307.601 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 800.987 311.985 L 805.253 313.321 C 803.28 319.665 797.207 328.988 791.224 332.019 L 789.598 331.45 C 789.165 328.463 790.525 325.609 791.566 322.739 C 794.781 319.597 795.583 316.402 800.987 311.985 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 792.986 308.611 L 796.013 308.679 C 798.684 312.014 797.474 311.544 800.987 311.985 C 795.583 316.402 794.781 319.597 791.566 322.739 C 790.292 321.075 788.696 320.346 786.877 319.257 C 790.076 316.623 791.237 315.086 793.279 311.586 L 792.075 309.884 L 791.668 309.098 L 792.986 308.611 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 792.075 309.884 L 793.279 311.586 C 791.237 315.086 790.076 316.623 786.877 319.257 C 784.072 316.386 783.551 315.132 785.024 311.63 C 787.396 309.617 789.012 309.911 792.075 309.884 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 691.422 415.596 L 692.859 415.309 C 699.629 419.276 737.356 415.991 746.783 415.047 L 749.831 419.299 C 751.709 419.682 754.915 420.226 756.637 420.736 C 755.021 422.988 753.247 424.867 752.996 427.512 L 750.717 426.623 C 746.877 426.724 742.355 426.377 739.299 428.495 L 738.734 429.537 C 739.633 431.228 740.333 432.395 741.384 433.996 L 740.373 435.33 C 732.076 435.814 731.343 434.103 727.034 441.246 C 722.605 440.978 717.949 440.218 713.752 440.096 L 714.501 438.9 C 716.382 436.087 714.07 436.256 716.355 433.409 L 723.12 432.816 C 726.727 432.439 731.674 432.424 735.397 432.3 C 735.254 425.111 733.982 425.369 727.654 421.893 C 716.103 422.32 698.91 425.54 691.422 415.596 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 723.12 432.816 C 726.727 432.439 731.674 432.424 735.397 432.3 C 724.161 435.45 729.123 437.922 714.501 438.9 C 716.382 436.087 714.07 436.256 716.355 433.409 L 723.12 432.816 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 749.831 419.299 C 751.709 419.682 754.915 420.226 756.637 420.736 C 755.021 422.988 753.247 424.867 752.996 427.512 L 750.717 426.623 L 746.107 425.277 L 745.896 423.846 L 749.831 419.299 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 813.634 331.899 C 815.822 329.796 818.362 328.409 820.952 326.805 L 822.154 331.796 L 828.407 327.657 C 832.19 329.496 832.677 328.899 833.982 331.889 C 833.133 334.639 832.145 337.329 831.877 340.176 C 833.492 341.138 835.404 342.552 837.119 342.81 L 834.071 347.843 C 835.792 350.055 836.816 351.666 839.119 353.295 C 840.788 358.544 839.186 364.665 833.865 366.837 C 830.811 364.901 829.906 364.536 826.53 363.276 C 825.662 359.82 819.673 350.016 817.55 346.332 C 817.876 345 817.929 344.666 818.587 343.439 L 819.048 342.597 L 817.587 340.932 C 816.272 336.927 815.645 335.619 813.634 331.899 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 828.407 327.657 C 832.19 329.496 832.677 328.899 833.982 331.889 C 833.133 334.639 832.145 337.329 831.877 340.176 C 833.492 341.138 835.404 342.552 837.119 342.81 L 834.071 347.843 C 833.283 345.414 831.667 344.664 829.53 343.114 C 826.253 340.202 824.248 335.718 822.154 331.796 L 828.407 327.657 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 772.211 415.686 C 775.218 415.429 775.46 414.116 777.031 411.916 C 777.603 415.895 780.048 417.958 781.427 421.929 C 780.472 423.069 778.935 424.786 778.118 425.954 L 778.609 426.578 C 776.531 427.813 776.061 427.895 774.918 429.914 L 775.204 431.185 C 773.848 432.524 772.894 433.793 771.105 434.293 C 762.451 431.8 761.059 431.43 752.996 427.512 C 753.247 424.867 755.021 422.988 756.637 420.736 C 754.915 420.226 751.709 419.682 749.831 419.299 L 746.783 415.047 L 751.633 414.998 C 760.541 413.692 764.239 412.087 772.211 415.686 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 763.265 420.198 C 767.318 421.048 773.098 426.2 774.918 429.914 L 775.204 431.185 C 773.848 432.524 772.894 433.793 771.105 434.293 C 762.451 431.8 761.059 431.43 752.996 427.512 C 753.247 424.867 755.021 422.988 756.637 420.736 C 758.682 421.068 761.156 420.516 763.265 420.198 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 746.783 415.047 L 751.633 414.998 C 756.079 415.1 758.381 414.443 761.846 416.533 L 763.265 420.198 C 761.156 420.516 758.682 421.068 756.637 420.736 C 754.915 420.226 751.709 419.682 749.831 419.299 L 746.783 415.047 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 772.211 415.686 C 775.218 415.429 775.46 414.116 777.031 411.916 C 777.603 415.895 780.048 417.958 781.427 421.929 C 780.472 423.069 778.935 424.786 778.118 425.954 C 775.172 422.081 776.365 419.458 772.211 415.686 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 810.077 322.833 L 811.291 326.174 C 811.762 330.343 811.089 328.866 813.634 331.899 C 815.645 335.619 816.272 336.927 817.587 340.932 L 819.048 342.597 L 818.587 343.439 C 817.929 344.666 817.876 345 817.55 346.332 C 819.673 350.016 825.662 359.82 826.53 363.276 C 824.089 363.067 822.491 362.9 820.005 363.006 C 819.674 365.189 820.092 365.623 821.047 367.738 L 818.937 365.981 C 818.081 365.236 817.6 364.467 816.911 363.555 L 818.101 361.13 C 815.215 360.781 813.193 358.859 810.848 357.08 L 814.923 353.827 C 813.635 350.93 812.445 348.666 810.942 345.876 C 807.566 344.096 802.71 340.982 799.122 341.099 C 797.076 340.135 795.666 338.748 793.933 337.283 C 800.491 334.225 802.055 328.056 810.077 322.833 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 810.077 322.833 L 811.291 326.174 C 811.762 330.343 811.089 328.866 813.634 331.899 C 815.645 335.619 816.272 336.927 817.587 340.932 L 816.973 342.208 C 810.599 345.514 809.05 337.21 803.192 335.642 C 800.26 337.115 800.888 337.415 799.122 341.099 C 797.076 340.135 795.666 338.748 793.933 337.283 C 800.491 334.225 802.055 328.056 810.077 322.833 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 814.923 353.827 C 817.192 356.592 819.21 357.528 818.101 361.13 C 815.215 360.781 813.193 358.859 810.848 357.08 L 814.923 353.827 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 840.946 377.388 L 841.345 375.534 L 839.095 373.06 L 839.797 371.395 L 842.585 370.731 C 845.104 372.593 845.407 373.134 845.955 376.871 C 846.031 377.39 846.099 377.91 846.16 378.431 L 847.902 381.876 L 846.437 382.28 C 843.752 384.625 838.289 390.565 836.263 392.095 C 832.005 393.453 824.491 394.668 823.258 396.045 L 823.166 396.472 L 820.527 401.702 C 816.15 400.24 816.322 399.495 813.911 396.021 C 811.677 396.936 811.585 396.924 809.138 396.833 C 810.243 396.187 810.352 395.907 811.281 394.95 L 810.329 390.262 C 811.731 387.166 812.059 387.884 816.136 386.103 C 822.617 386.395 836.951 382.196 840.946 377.388 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 813.911 396.021 C 817.288 395.855 819.917 395.51 823.166 396.472 L 820.527 401.702 C 816.15 400.24 816.322 399.495 813.911 396.021 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 694.471 391.544 L 695.808 392.14 C 695.811 394.828 696.523 396.258 698.863 398.373 C 707.556 406.236 699.731 408.236 713.295 407.589 C 719.782 407.279 727.044 407.156 733.712 406.534 C 737.051 407.279 737.689 407.346 740.106 410 C 721.658 415.103 705.819 404.867 692.859 415.309 L 691.422 415.596 C 689.103 414.236 688.123 411.803 686.74 409.365 C 690.508 407.258 688.995 407.732 693.479 408.05 C 694.911 407.122 695.858 406.262 697.136 405.164 C 697.5 400.549 694.48 400.223 694.468 392.192 L 694.471 391.544 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 685.463 375.476 C 696.851 375.63 706.27 375.433 717.567 374.016 L 695.808 392.14 L 694.471 391.544 C 689.84 385.853 688.212 382.249 685.463 375.476 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 686.035 364.749 C 689.708 366.163 692.036 365.745 695.96 365.503 C 690.176 369.697 679.17 379.564 677.321 386.035 C 673.566 388.386 674.73 388.437 670.963 388.102 C 669.832 382.406 668.093 382.226 667.058 377.768 C 677.035 374.316 678.559 371.351 686.035 364.749 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 733.712 406.534 C 743.023 404.761 760.177 385.592 768.971 386.18 C 770.594 388.494 770.705 388.8 770.635 391.623 C 766.829 396.011 761.007 394.946 758.227 396.669 C 751.604 400.773 748.023 406.648 740.106 410 C 737.689 407.346 737.051 407.279 733.712 406.534 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 801.981 395.094 C 804.073 394.895 805.768 394.977 807.469 393.834 C 806.578 387.41 799.581 387.592 798.794 381.033 C 800.933 374.44 805.668 374.789 811.251 375.948 C 811.812 381.042 812.268 380.195 815.791 384.903 L 816.136 386.103 C 812.059 387.884 811.731 387.166 810.329 390.262 L 811.281 394.95 C 810.352 395.907 810.243 396.187 809.138 396.833 L 801.981 395.094 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 811.251 375.948 C 811.812 381.042 812.268 380.195 815.791 384.903 C 812.135 385.696 809.636 386.439 806.577 384.798 C 807.019 381.507 809.71 379.805 811.251 375.948 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 844.579 350.501 C 847.475 357.751 847.748 365.984 852.482 370.768 C 851.193 371.723 848.839 373.158 848.262 374.416 C 844.443 367.279 837.7 370.518 833.865 366.837 C 839.186 364.665 840.788 358.544 839.119 353.295 L 844.579 350.501 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 777.031 411.916 C 782.254 411.822 792.002 421.776 796.531 425.808 C 792.927 426.934 792.349 426.926 788.594 426.829 C 786.868 429.076 786.544 429.441 785.738 432.14 L 782.631 432.771 C 781.253 430.954 779.84 428.546 778.609 426.578 L 778.118 425.954 C 778.935 424.786 780.472 423.069 781.427 421.929 C 780.048 417.958 777.603 415.895 777.031 411.916 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 781.427 421.929 C 784.394 424.429 785.142 425.035 788.594 426.829 C 786.868 429.076 786.544 429.441 785.738 432.14 L 782.631 432.771 C 781.253 430.954 779.84 428.546 778.609 426.578 L 778.118 425.954 C 778.935 424.786 780.472 423.069 781.427 421.929 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 821.047 367.738 C 820.092 365.623 819.674 365.189 820.005 363.006 C 822.491 362.9 824.089 363.067 826.53 363.276 C 829.906 364.536 830.811 364.901 833.865 366.837 C 837.7 370.518 844.443 367.279 848.262 374.416 L 846.16 378.431 C 846.099 377.91 846.031 377.39 845.955 376.871 C 845.407 373.134 845.104 372.593 842.585 370.731 L 839.797 371.395 L 839.095 373.06 L 841.345 375.534 L 840.946 377.388 C 832.819 369.97 825.649 373.356 821.047 367.738 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 670.963 388.102 C 674.73 388.437 673.566 388.386 677.321 386.035 C 682.819 393.49 686.417 404.861 693.479 408.05 C 688.995 407.732 690.508 407.258 686.74 409.365 C 681.16 402.269 675.023 396.41 670.963 388.102 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 720.915 342.17 C 725.766 343.475 729.95 343.952 732.957 347.889 C 728.837 350.96 724.687 350.391 719.489 350.388 C 715.038 349.82 715.025 349.157 713.44 345.369 L 715.195 343.605 L 720.915 342.17 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 725.461 314.757 L 725.905 314.872 C 728.613 321.049 730.085 318.315 735.68 319.471 C 725.797 320.716 724.679 327.918 721.599 336.254 C 719.089 335.344 718.773 335.214 717.25 333.039 C 719.412 325.313 725.013 322.031 725.461 314.757 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 756.377 352.223 C 760.256 351.012 766.125 348.809 769.981 348.95 C 769.494 351.727 769.701 353.491 767.225 354.872 C 762.115 357.723 759.586 360.005 753.974 359.163 C 754.147 357.431 755.674 353.996 756.377 352.223 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 686.035 364.749 C 689.668 362.014 700.149 355.787 704.501 352.941 L 705.578 357.746 C 702.522 360.471 699.459 363.41 695.96 365.503 C 692.036 365.745 689.708 366.163 686.035 364.749 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 744.998 346.915 C 747.552 350.311 751.873 351.015 756.377 352.223 C 755.674 353.996 754.147 357.431 753.974 359.163 C 751.746 355.589 747.078 356.152 741.495 351.16 C 743.602 349.961 743.776 349.145 744.998 346.915 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 704.501 352.941 C 707.364 349.844 710.118 347.89 713.44 345.369 C 715.025 349.157 715.038 349.82 719.489 350.388 C 716.277 351.432 707.944 355.516 705.578 357.746 L 704.501 352.941 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 737.703 331.444 C 738.593 332.985 739.484 334.213 739.78 335.953 L 737.858 341.412 C 736.956 341.444 736.053 341.451 735.15 341.431 C 731.756 341.326 731.087 340.709 729.186 338.704 L 729.547 336.371 C 731.559 333.165 734.091 332.714 737.703 331.444 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 739.78 335.953 C 739.96 341.661 742.072 341.114 744.998 346.915 C 743.776 349.145 743.602 349.961 741.495 351.16 C 740.108 348.94 739.712 347.58 738.83 345.163 C 738.825 342.61 739.146 343.544 737.858 341.412 L 739.78 335.953 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 810.848 357.08 C 813.193 358.859 815.215 360.781 818.101 361.13 L 816.911 363.555 C 817.6 364.467 818.081 365.236 818.937 365.981 C 816.166 365.577 815.077 365.287 812.406 364.433 C 810.232 361.074 810.919 361.684 810.848 357.08 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 729.844 375.819 C 732.975 375.841 731.911 375.459 734.276 376.922 C 735.654 383.112 736.277 387.193 730.52 391.436 C 727.563 392.356 725.168 391.269 722.221 390.346 L 717.57 388.979 L 716.695 386.629 C 718.59 380.902 724.374 378.789 729.844 375.819 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 767.999 343.608 C 762.94 344.675 761.611 345.092 758.457 340.726 C 758.657 337.041 758.122 338.448 760.658 335.474 C 765.012 333.958 764.156 334.541 769.26 335.493 L 771.828 337.746 L 767.999 343.608 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 764.836 371.885 C 770.227 371.969 772.696 371.841 775.412 376.759 C 774.521 380.669 775.291 379.31 772.667 382.075 C 769.174 380.943 764.736 380.016 762.64 377.185 C 762.62 373.897 762.541 374.945 764.836 371.885 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 769.26 335.493 C 771.269 333.411 771.088 330.658 771.286 327.744 L 773.628 326.343 C 775.707 332.044 775.335 332.855 771.828 337.746 L 769.26 335.493 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 330.651 288.853 C 349.018 288.965 368.268 291.231 386.898 291.338 C 410.096 291.47 432.953 289.92 456.12 288.831 C 455.51 299.907 455.222 308.924 456.013 320.006 C 438.68 319.218 398.098 316.74 381.326 313.928 L 381.008 391.077 C 402.019 391.059 434.897 391.558 454.969 389.143 C 453.738 398.377 453.814 406.6 454.906 415.823 C 435.624 413.828 401.044 414.064 381.052 413.886 L 380.975 439.5 C 380.889 458.429 380.959 477.359 381.185 496.287 C 407.193 496.098 430.051 495.601 456.061 493.92 C 455.542 504.129 455.01 514.003 456.012 524.187 C 414.657 523.472 372.051 524.193 330.522 523.99 C 332.849 445.629 332.892 367.217 330.651 288.853 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 668.907 624.891 C 672.203 625.901 673.991 628.604 676.273 631.269 C 678.37 632.742 678.504 633.037 679.422 635.443 L 679.048 639.646 C 682.356 642.419 683.026 639.321 686.733 641.764 L 695.503 642.172 C 702.788 643.226 708.554 643.771 715.35 646.727 C 750.474 667.842 752.181 690.334 754.642 729.016 L 656.817 728.98 C 662.096 774.143 672.396 808.953 728.65 791.27 C 733.555 789.728 741.397 779.867 746.035 782.195 C 748.463 810.304 715.617 818.881 693.95 820.468 C 673.29 821.847 649.952 814.284 634.317 801.045 C 616.989 786.373 607.117 759.142 606.864 736.791 C 606.399 695.823 616.43 667.23 651.914 647.5 L 658.14 645.469 C 656.465 641.122 656.437 642.651 657.096 638.242 C 659.618 638.08 661.468 638.312 663.305 636.746 L 663.121 634.027 L 663.369 630.541 L 664.778 631.013 C 665.717 629.172 667.653 626.645 668.907 624.891 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 668.907 624.891 C 672.203 625.901 673.991 628.604 676.273 631.269 C 678.37 632.742 678.504 633.037 679.422 635.443 L 679.048 639.646 C 682.356 642.419 683.026 639.321 686.733 641.764 C 681.56 642.011 674.119 642.078 669.294 643.362 L 658.14 645.469 C 656.465 641.122 656.437 642.651 657.096 638.242 C 659.618 638.08 661.468 638.312 663.305 636.746 L 663.121 634.027 L 663.369 630.541 L 664.778 631.013 C 665.717 629.172 667.653 626.645 668.907 624.891 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 663.121 634.027 C 667.448 635.92 667.51 635.143 669.716 638.493 C 669.593 640.118 669.452 641.741 669.294 643.362 L 658.14 645.469 C 656.465 641.122 656.437 642.651 657.096 638.242 C 659.618 638.08 661.468 638.312 663.305 636.746 L 663.121 634.027 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 668.907 624.891 C 672.203 625.901 673.991 628.604 676.273 631.269 C 670.737 632.734 669.67 633.465 664.778 631.013 C 665.717 629.172 667.653 626.645 668.907 624.891 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 669.204 666.184 C 677.715 657.064 685.204 654.87 695.541 661.83 C 701.065 666.749 703.041 669.694 705.712 676.591 C 707.612 689.903 708.185 700.083 708.946 713.515 C 691.601 713.649 674.256 713.665 656.911 713.563 C 659.569 696.633 659.732 681.733 669.204 666.184 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 669.204 666.184 C 677.715 657.064 685.204 654.87 695.541 661.83 C 701.065 666.749 703.041 669.694 705.712 676.591 L 699.214 677.583 C 684.804 680.643 680.216 674.581 669.204 666.184 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 695.541 661.83 C 701.065 666.749 703.041 669.694 705.712 676.591 L 699.214 677.583 C 694.247 668.634 692.068 673.456 695.541 661.83 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 326.38 176.427 C 328.186 176.318 330.373 176.355 332.215 176.334 C 332.758 177.299 332.715 203.356 333.138 207.731 C 316.605 204.904 291.133 205.779 273.33 203.9 C 272.944 269.421 271.283 350.197 276.297 414.072 C 257.544 413.702 238.105 413.972 219.301 413.988 C 220.371 404.905 220.792 390.217 221.331 380.752 C 222.909 362.72 222.122 334.342 222.129 315.893 L 221.99 203.969 C 209.015 205.399 189.6 205.711 176.111 206.341 L 176.106 176.665 C 207.23 179.801 236.426 182.681 267.755 181.144 C 287.36 180.183 306.748 177.832 326.38 176.427 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 185.086 575.811 C 206.547 582.505 219.591 581.436 240.667 575.917 C 237.361 603.365 237.739 640.519 237.694 668.377 L 237.855 785.173 C 257.695 785.324 296.472 785.978 315.332 783.243 C 314.708 791.99 314.815 799.528 314.931 808.251 C 315.306 811.816 315.617 810.696 314.432 813.541 C 271.363 813.055 228.29 813.068 185.221 813.58 C 186.134 773.267 186.43 732.943 186.109 692.621 C 186.466 653.682 186.125 614.739 185.086 575.811 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 846.437 382.28 C 851.827 383.906 856.356 387.597 859.036 392.549 C 862.345 398.802 861.995 401.132 862.068 408.016 L 863.613 413.567 L 862.625 414.64 L 860.81 414.655 C 863.212 416.772 863.924 416.994 866.8 418.193 C 867.426 415.979 867.584 415.37 868.618 413.333 C 871.177 411.843 871.636 411.647 873.072 409.09 C 877.013 418.197 890.476 430.927 900.974 435.144 C 904.429 441.405 902.386 443.621 905.797 447.116 C 907.296 446.239 908.47 445.931 910.097 445.394 C 919.593 447.856 916.188 472.165 934.367 466.045 C 938.045 464.807 933.651 458.01 934.32 455.411 C 935.731 449.931 938.43 444.483 940.434 439.005 C 944.94 443.006 947.554 446.231 948.332 452.341 C 947.707 453.871 946.107 457.499 945.858 458.935 C 943.993 465.219 937.75 475.707 933.769 480.992 C 929.521 483.437 925.588 483.821 920.8 484.66 C 919.781 485.343 918.726 485.94 917.893 486.805 C 916.485 487.445 914.976 488.275 913.504 488.494 L 908.489 490.002 L 907.959 488.753 C 905.369 492.515 903.661 495.026 900.611 498.397 C 896.173 502.84 894.12 504.274 888.025 506.077 C 883.331 506.369 878.086 505.51 875.942 510.176 C 870.441 507.726 856.245 499.789 852.52 499.923 C 851.686 500.78 849.908 502.576 849.006 503.091 C 842.173 506.984 827.112 485.496 820.669 481.428 C 813.623 476.979 814.623 476.834 806.15 474.381 C 810.873 470.732 810.35 472.824 811.978 468.013 L 807.838 465.305 C 804.247 467.725 802.353 469.371 798.11 470.02 C 796.487 472.267 797.016 471.554 793.652 472.48 C 782.804 470.912 773.591 471.567 762.756 472.069 C 754.791 473.635 746.337 474.169 740.692 467.39 C 739.77 467.71 737.918 468.246 737.219 468.756 C 733.867 468.761 731.22 468.297 728.709 470.241 L 727.836 472.008 L 724.453 469.525 C 729.531 463.586 738.399 457.293 733.24 449.514 L 738.169 448.565 C 740.13 448.238 747.471 445.279 749.731 444.419 C 756.069 442.329 766.551 439.2 771.105 434.293 C 772.894 433.793 773.848 432.524 775.204 431.185 L 774.918 429.914 C 776.061 427.895 776.531 427.813 778.609 426.578 C 779.84 428.546 781.253 430.954 782.631 432.771 L 785.738 432.14 C 788.952 432.292 792.147 432.934 794.618 431.249 C 796.824 426.454 797.522 426.978 802.12 425.571 L 803.569 426.176 L 804.089 424.817 C 801.576 420.462 795.85 415.118 792.204 411.332 C 790.123 407.398 784.444 405.5 783.691 398.576 L 785.961 396.805 C 788.357 395.434 788.813 393.631 790.019 391.061 C 794.256 391.578 798.466 392.427 801.981 395.094 L 809.138 396.833 C 811.585 396.924 811.677 396.936 813.911 396.021 C 816.322 399.495 816.15 400.24 820.527 401.702 L 823.166 396.472 L 823.258 396.045 C 824.491 394.668 832.005 393.453 836.263 392.095 C 838.289 390.565 843.752 384.625 846.437 382.28 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 900.974 435.144 C 904.429 441.405 902.386 443.621 905.797 447.116 C 907.296 446.239 908.47 445.931 910.097 445.394 C 919.593 447.856 916.188 472.165 934.367 466.045 C 938.045 464.807 933.651 458.01 934.32 455.411 C 935.731 449.931 938.43 444.483 940.434 439.005 C 944.94 443.006 947.554 446.231 948.332 452.341 C 947.707 453.871 946.107 457.499 945.858 458.935 C 943.993 465.219 937.75 475.707 933.769 480.992 C 929.521 483.437 925.588 483.821 920.8 484.66 C 919.781 485.343 918.726 485.94 917.893 486.805 C 916.485 487.445 914.976 488.275 913.504 488.494 L 908.489 490.002 L 907.959 488.753 C 905.369 492.515 903.661 495.026 900.611 498.397 C 896.173 502.84 894.12 504.274 888.025 506.077 C 883.331 506.369 878.086 505.51 875.942 510.176 C 870.441 507.726 856.245 499.789 852.52 499.923 C 851.686 500.78 849.908 502.576 849.006 503.091 C 842.173 506.984 827.112 485.496 820.669 481.428 C 813.623 476.979 814.623 476.834 806.15 474.381 C 810.873 470.732 810.35 472.824 811.978 468.013 L 813.634 466.411 C 821.733 466.874 830.413 474.947 837.82 477.024 C 840.461 478.023 845.431 479.635 847.394 481.29 L 848.351 481.88 C 851.71 485.073 853.12 486.293 858.059 486.05 C 872.533 486.194 871.91 483.442 884.678 477.253 C 887.428 473.77 888.12 473.06 891.589 470.297 L 893.67 467.471 L 895.43 467.033 L 898.128 461.572 L 894.157 455.857 C 896.715 453.731 897.189 452.691 898.84 449.928 L 897.314 443.457 C 898.618 440.724 899.839 437.951 900.974 435.144 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 900.974 435.144 C 904.429 441.405 902.386 443.621 905.797 447.116 C 907.296 446.239 908.47 445.931 910.097 445.394 C 919.593 447.856 916.188 472.165 934.367 466.045 C 938.045 464.807 933.651 458.01 934.32 455.411 C 935.731 449.931 938.43 444.483 940.434 439.005 C 944.94 443.006 947.554 446.231 948.332 452.341 C 947.707 453.871 946.107 457.499 945.858 458.935 C 943.993 465.219 937.75 475.707 933.769 480.992 C 931.953 475.68 930.82 473.702 925.637 471.308 C 923.76 471.145 920.837 470.711 919.129 471.176 C 919.125 469.369 918.925 469.274 918.121 467.699 C 914.775 466.458 907.077 466.915 901.115 465.731 C 907.966 460.849 908.855 467.501 914.691 460.323 C 915.173 456.931 915.354 458.334 913.62 454.956 C 906.167 450.463 902.563 455.263 898.128 461.572 L 894.157 455.857 C 896.715 453.731 897.189 452.691 898.84 449.928 L 897.314 443.457 C 898.618 440.724 899.839 437.951 900.974 435.144 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 925.637 471.308 C 938.271 467.252 937.206 459.775 943.363 457.638 L 945.858 458.935 C 943.993 465.219 937.75 475.707 933.769 480.992 C 931.953 475.68 930.82 473.702 925.637 471.308 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 900.974 435.144 C 904.429 441.405 902.386 443.621 905.797 447.116 C 903.454 448.261 901.391 449.464 898.84 449.928 L 897.314 443.457 C 898.618 440.724 899.839 437.951 900.974 435.144 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 811.978 468.013 C 813.4 469.927 842.689 496.637 844.371 497.802 C 849.456 498.112 851.901 494.359 853.899 495.078 C 872.432 501.752 865.793 505.947 888.025 506.077 C 883.331 506.369 878.086 505.51 875.942 510.176 C 870.441 507.726 856.245 499.789 852.52 499.923 C 851.686 500.78 849.908 502.576 849.006 503.091 C 842.173 506.984 827.112 485.496 820.669 481.428 C 813.623 476.979 814.623 476.834 806.15 474.381 C 810.873 470.732 810.35 472.824 811.978 468.013 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 919.129 471.176 C 920.837 470.711 923.76 471.145 925.637 471.308 C 930.82 473.702 931.953 475.68 933.769 480.992 C 929.521 483.437 925.588 483.821 920.8 484.66 C 918.175 481.916 918.861 476.87 918.889 472.877 L 919.129 471.176 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 907.959 488.753 C 908.587 487.146 909.611 485.988 910.653 484.606 C 918.643 481.967 917.827 480.833 918.889 472.877 C 918.861 476.87 918.175 481.916 920.8 484.66 C 919.781 485.343 918.726 485.94 917.893 486.805 C 916.485 487.445 914.976 488.275 913.504 488.494 L 908.489 490.002 L 907.959 488.753 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 891.589 470.297 C 894.533 472.897 895.317 472.735 896.288 475.935 C 893.431 478.03 888.432 477.368 884.678 477.253 C 887.428 473.77 888.12 473.06 891.589 470.297 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 846.437 382.28 C 851.827 383.906 856.356 387.597 859.036 392.549 C 862.345 398.802 861.995 401.132 862.068 408.016 L 863.613 413.567 L 862.625 414.64 L 860.81 414.655 L 857.215 413.84 C 854.447 414.581 852.919 413.915 850.323 413.091 L 848.508 413.668 C 847.612 417.177 848.09 418.585 848.727 422.138 L 846.728 423.137 C 846.77 424.496 846.791 428.181 847.026 429.309 C 850.987 431.195 850.891 430.368 852.557 433.561 C 852.135 439.503 848.512 440.903 848.93 446.393 C 844.558 447.817 842.541 447.29 838.237 446.229 L 834.164 447.061 C 823.181 450.002 824.278 443.798 817.903 437.626 L 811.852 435.633 L 811.277 433.486 C 806.427 431.949 804.08 436.935 797.422 437.306 L 794.618 431.249 C 796.824 426.454 797.522 426.978 802.12 425.571 L 803.569 426.176 L 804.089 424.817 C 801.576 420.462 795.85 415.118 792.204 411.332 C 790.123 407.398 784.444 405.5 783.691 398.576 L 785.961 396.805 C 788.357 395.434 788.813 393.631 790.019 391.061 C 794.256 391.578 798.466 392.427 801.981 395.094 L 809.138 396.833 C 811.585 396.924 811.677 396.936 813.911 396.021 C 816.322 399.495 816.15 400.24 820.527 401.702 L 823.166 396.472 L 823.258 396.045 C 824.491 394.668 832.005 393.453 836.263 392.095 C 838.289 390.565 843.752 384.625 846.437 382.28 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 846.437 382.28 C 851.827 383.906 856.356 387.597 859.036 392.549 C 862.345 398.802 861.995 401.132 862.068 408.016 L 863.613 413.567 L 862.625 414.64 L 860.81 414.655 L 857.215 413.84 C 850.82 408.871 847.576 409.243 837.105 400.103 L 835.99 399.428 C 834.439 396.457 835.276 396.322 836.263 392.095 C 838.289 390.565 843.752 384.625 846.437 382.28 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 833.444 421.938 L 836.439 423.947 C 836.575 425.854 836.728 425.941 835.787 427.664 C 837.111 431.565 836.235 430.714 839.163 433.078 C 846.059 436.53 842.207 439.421 848.93 446.393 C 844.558 447.817 842.541 447.29 838.237 446.229 L 834.164 447.061 C 823.181 450.002 824.278 443.798 817.903 437.626 L 811.852 435.633 C 814.465 429.831 814.581 432.693 818.872 427.936 C 822.518 425.971 825.314 426.943 829.456 427.611 C 832.51 425.872 831.739 425.888 833.444 421.938 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 817.903 437.626 C 819.24 435.794 821.928 433.996 823.812 432.566 C 826.255 439.953 832.004 441.49 834.164 447.061 C 823.181 450.002 824.278 443.798 817.903 437.626 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 823.812 432.566 C 835.281 432.777 834.74 436.342 838.237 446.229 L 834.164 447.061 C 832.004 441.49 826.255 439.953 823.812 432.566 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 792.204 411.332 C 795.382 407.911 795.933 408.365 800.151 408.338 C 801.185 411.738 801.441 413.835 804.377 415.765 C 810.046 417.603 814.57 410.892 817.661 416.189 C 816.595 419.567 814.809 423.686 815.792 426.931 C 817.173 427.665 817.276 427.849 818.872 427.936 C 814.581 432.693 814.465 429.831 811.852 435.633 L 811.277 433.486 C 806.427 431.949 804.08 436.935 797.422 437.306 L 794.618 431.249 C 796.824 426.454 797.522 426.978 802.12 425.571 L 803.569 426.176 L 804.089 424.817 C 801.576 420.462 795.85 415.118 792.204 411.332 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 833.444 421.938 L 834.005 420.28 C 835.325 421.254 836.653 422.219 837.988 423.173 L 839.883 422.817 C 841.798 419.837 841.37 421.22 841.332 417.497 C 839.596 415.981 839.009 415.429 837.587 413.62 C 839.266 411.786 840.109 410.417 842.475 409.881 C 847.668 412.82 846.856 417.764 846.728 423.137 C 846.77 424.496 846.791 428.181 847.026 429.309 C 850.987 431.195 850.891 430.368 852.557 433.561 C 852.135 439.503 848.512 440.903 848.93 446.393 C 842.207 439.421 846.059 436.53 839.163 433.078 C 836.235 430.714 837.111 431.565 835.787 427.664 C 836.728 425.941 836.575 425.854 836.439 423.947 L 833.444 421.938 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 835.787 427.664 C 839.274 426.647 837.861 426.553 841.103 427.928 L 842.191 429.932 L 841.394 432.176 L 839.163 433.078 C 836.235 430.714 837.111 431.565 835.787 427.664 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 790.019 391.061 C 794.256 391.578 798.466 392.427 801.981 395.094 L 809.138 396.833 C 811.585 396.924 811.677 396.936 813.911 396.021 C 816.322 399.495 816.15 400.24 820.527 401.702 L 819.307 402.707 C 809.18 405.909 794.547 400.542 790.019 391.061 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 823.166 396.472 L 823.258 396.045 C 824.491 394.668 832.005 393.453 836.263 392.095 C 835.276 396.322 834.439 396.457 835.99 399.428 L 837.105 400.103 L 833.833 403.304 C 829.945 399.54 826.806 402.456 823.236 405.828 L 819.307 402.707 L 820.527 401.702 L 823.166 396.472 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 785.961 396.805 C 794.457 398.559 793.376 405.884 800.151 408.338 C 795.933 408.365 795.382 407.911 792.204 411.332 C 790.123 407.398 784.444 405.5 783.691 398.576 L 785.961 396.805 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 823.236 405.828 C 826.806 402.456 829.945 399.54 833.833 403.304 C 832.905 407.235 831.692 411.973 827.431 413.453 C 822.973 411.64 823.778 410.536 823.236 405.828 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 775.204 431.185 C 778.291 433.767 780.611 436.652 779.388 440.97 L 778.053 441.644 L 781.589 446.575 L 781.271 447.796 L 785.134 446.768 C 792.074 447.863 791.946 448.401 796.505 454.089 C 800.966 455.388 805.667 456.382 809.312 459.203 C 810.574 461.999 810.652 463.182 807.838 465.305 C 804.247 467.725 802.353 469.371 798.11 470.02 C 796.487 472.267 797.016 471.554 793.652 472.48 C 782.804 470.912 773.591 471.567 762.756 472.069 C 754.791 473.635 746.337 474.169 740.692 467.39 C 739.77 467.71 737.918 468.246 737.219 468.756 C 733.867 468.761 731.22 468.297 728.709 470.241 L 727.836 472.008 L 724.453 469.525 C 729.531 463.586 738.399 457.293 733.24 449.514 L 738.169 448.565 C 740.13 448.238 747.471 445.279 749.731 444.419 C 756.069 442.329 766.551 439.2 771.105 434.293 C 772.894 433.793 773.848 432.524 775.204 431.185 z"/>
<path transform="translate(0,0)" fill="url(#Gradient3)" d="M 775.204 431.185 C 778.291 433.767 780.611 436.652 779.388 440.97 L 778.053 441.644 L 781.589 446.575 L 781.271 447.796 L 784.312 452.238 C 771.764 456.312 762.825 456.133 750.004 457.656 C 748.967 457.779 742.613 465.771 740.692 467.39 C 739.77 467.71 737.918 468.246 737.219 468.756 C 733.867 468.761 731.22 468.297 728.709 470.241 L 727.836 472.008 L 724.453 469.525 C 729.531 463.586 738.399 457.293 733.24 449.514 L 738.169 448.565 C 740.13 448.238 747.471 445.279 749.731 444.419 C 756.069 442.329 766.551 439.2 771.105 434.293 C 772.894 433.793 773.848 432.524 775.204 431.185 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 775.204 431.185 C 778.291 433.767 780.611 436.652 779.388 440.97 L 778.053 441.644 C 774.055 442.107 773.143 442.062 769.829 444.299 C 768.126 449.559 761.098 448.053 755.792 448.36 C 752.801 447.109 752.029 446.898 749.731 444.419 C 756.069 442.329 766.551 439.2 771.105 434.293 C 772.894 433.793 773.848 432.524 775.204 431.185 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 738.169 448.565 C 740.13 448.238 747.471 445.279 749.731 444.419 C 752.029 446.898 752.801 447.109 755.792 448.36 C 754.728 450.012 754.745 450.028 753.279 451.317 C 747.84 452.467 746.763 452.579 741.392 451.208 C 740.472 450.467 738.976 449.333 738.169 448.565 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 769.829 444.299 C 773.143 442.062 774.055 442.107 778.053 441.644 L 781.589 446.575 C 779.037 447.18 776.494 447.82 773.959 448.494 C 770.876 447.24 771.809 447.672 769.829 444.299 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 785.134 446.768 C 792.074 447.863 791.946 448.401 796.505 454.089 C 800.966 455.388 805.667 456.382 809.312 459.203 C 810.574 461.999 810.652 463.182 807.838 465.305 C 804.247 467.725 802.353 469.371 798.11 470.02 C 793.314 461.679 791.271 458.379 784.312 452.238 L 781.271 447.796 L 785.134 446.768 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 889.806 462.427 C 893.318 464.903 892.322 463.545 893.67 467.471 L 891.589 470.297 C 888.12 473.06 887.428 473.77 884.678 477.253 C 871.91 483.442 872.533 486.194 858.059 486.05 C 853.12 486.293 851.71 485.073 848.351 481.88 L 847.394 481.29 L 850.258 476.642 C 864.45 482.006 879.972 471.905 889.806 462.427 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 848.351 481.88 C 852.561 478.265 859.06 477.567 863.475 481.039 C 862.564 484.335 861.457 484.911 858.059 486.05 C 853.12 486.293 851.71 485.073 848.351 481.88 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 801.957 449.5 C 815.915 454.213 822.049 468.211 836.787 472.246 C 840.913 473.089 847.368 473.846 850.258 476.642 L 847.394 481.29 C 845.431 479.635 840.461 478.023 837.82 477.024 C 830.413 474.947 821.733 466.874 813.634 466.411 L 811.978 468.013 L 807.838 465.305 C 810.652 463.182 810.574 461.999 809.312 459.203 C 805.667 456.382 800.966 455.388 796.505 454.089 C 799.205 452.342 799.767 452.005 801.957 449.5 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 873.072 409.09 C 877.013 418.197 890.476 430.927 900.974 435.144 C 899.839 437.951 898.618 440.724 897.314 443.457 C 897.035 442.02 896.626 441.379 896.025 440.047 C 885.735 434.34 875.506 420.467 866.8 418.193 C 867.426 415.979 867.584 415.37 868.618 413.333 C 871.177 411.843 871.636 411.647 873.072 409.09 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 778.609 426.578 C 779.84 428.546 781.253 430.954 782.631 432.771 C 784.517 435.565 784.853 436.007 785.194 439.485 L 788.372 441.959 C 787.153 443.353 785.246 445.062 785.134 446.768 L 781.271 447.796 L 781.589 446.575 L 778.053 441.644 L 779.388 440.97 C 780.611 436.652 778.291 433.767 775.204 431.185 L 774.918 429.914 C 776.061 427.895 776.531 427.813 778.609 426.578 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 887.227 449.975 C 889.83 451.876 892.174 453.327 894.157 455.857 L 898.128 461.572 L 895.43 467.033 L 893.67 467.471 C 892.322 463.545 893.318 464.903 889.806 462.427 L 886.849 455.643 L 887.227 449.975 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 848.727 422.138 C 851.54 421.23 853.417 420.246 856.136 421.448 C 858.128 424.324 858.038 423.258 857.629 425.977 C 851.309 429.97 850.341 425.743 847.026 429.309 C 846.791 428.181 846.77 424.496 846.728 423.137 L 848.727 422.138 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 788.372 441.959 C 793.228 447.433 794.923 447.074 801.957 449.5 C 799.767 452.005 799.205 452.342 796.505 454.089 C 791.946 448.401 792.074 447.863 785.134 446.768 C 785.246 445.062 787.153 443.353 788.372 441.959 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 794.618 431.249 L 797.422 437.306 C 792.401 435.788 787.46 433.503 785.194 439.485 C 784.853 436.007 784.517 435.565 782.631 432.771 L 785.738 432.14 C 788.952 432.292 792.147 432.934 794.618 431.249 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 886.849 455.643 C 885.419 452.491 882.356 447.044 883.814 443.787 C 886.584 445.615 885.936 445.992 887.227 449.975 L 886.849 455.643 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 427.207 647.015 C 451.607 650.688 449.035 658.108 477.628 646.203 C 478.904 648.904 480.893 652.811 481.751 655.629 C 493.555 694.411 503.926 733.51 518.617 771.344 C 521.063 758.303 531.361 731.701 536.052 718.004 C 544.093 694.525 551.028 671.442 558.602 647.904 C 561.414 649.204 566.229 651.528 569.186 652.038 C 573.004 652.696 581.254 649.889 584.322 651.605 C 584.344 655.012 577.061 670.15 575.205 674.296 C 564.061 700.646 552.818 726.442 543.147 753.414 C 535.957 773.467 530.41 794.401 522.376 814.049 C 512.399 812.642 496.493 813.247 486.095 813.526 C 482.891 803.654 478.275 793.685 475.418 783.651 C 462.169 737.107 442.754 692.523 427.207 647.015 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 350.963 647.774 C 366.833 654.621 383.831 654.622 399.396 647 C 396.88 664.851 396.352 693.618 396.233 711.621 C 395.718 744.196 396.485 776.78 398.535 809.295 C 398.948 810.957 398.73 811.107 398.183 812.713 C 393.955 814.11 357.18 813.343 351.045 813.299 C 352.41 782.647 352.998 751.966 352.809 721.285 C 352.782 699.007 352.746 669.828 350.963 647.774 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 617.015 524.887 C 623.473 523.896 629.431 523.4 635.846 525.096 L 643.09 529.072 C 645.46 530.197 649.884 533.404 651.216 535.337 C 648.972 537.935 649.559 536.855 649.781 541.041 C 648.625 542.443 646.723 544.6 645.74 546.01 C 646.386 549.274 648.115 551.617 651.038 553.304 C 652.512 556.081 653.414 557.445 653.932 560.619 L 655.687 563.927 L 658.106 559.972 L 656.974 559.013 C 657.134 557.312 657.287 557.142 657.898 555.6 C 660.961 554.48 666.177 557.139 670.361 558.162 C 672.852 558.805 673.981 559.01 676.257 560.312 C 682.157 566.235 688.48 574.333 694.265 579.448 L 692.928 581.291 C 688.069 581.511 688.878 580.708 685.92 583.372 C 685.055 586.581 685.338 587.886 685.653 591.101 L 683.183 589.101 L 681.677 592.131 C 678.302 590.144 677.828 590.015 674.892 592.036 C 672.624 596.005 673.785 595.121 670.052 597.345 L 670.099 597.752 C 668.448 598.709 667.457 599.096 666.322 600.651 L 665.376 601.67 C 663.84 603.398 661.897 605.728 660.306 607.311 C 657.866 605.825 655.31 603.903 652.963 602.231 C 652.985 606.288 652.701 609.396 654.265 613.118 C 651.786 617.028 651.697 619.242 651.979 623.978 L 649.46 624.832 C 647.44 626.521 646.079 627.913 643.629 628.878 C 640.755 623.922 639.656 623.156 639.893 617.602 C 639.217 617.61 638.541 617.613 637.865 617.612 C 632.951 617.581 634.019 618.036 631.263 615.231 C 629.449 617.155 629.394 617.348 626.912 617.884 L 622.025 614.378 C 617.909 610.26 617.256 608.01 615.732 602.521 C 611.921 598.134 610.888 597.376 605.928 594.316 C 607.678 591.771 610.474 591.986 612.969 588.032 C 609.757 584.452 608.208 583.547 608.431 579.259 C 605.992 577.198 605.823 576.859 604.919 573.787 C 603.768 574.368 602.968 574.96 601.72 574.829 C 591.035 562.899 594.23 557.117 600.019 543.195 C 601.665 542.474 604.774 541.992 606.647 541.626 C 606.487 539 607.476 536.805 608.321 534.267 C 607.79 532.612 606.35 531.752 604.95 530.593 L 609.177 527.95 C 612.761 527.258 613.811 526.563 617.015 524.887 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 617.015 524.887 C 623.473 523.896 629.431 523.4 635.846 525.096 L 643.09 529.072 C 645.46 530.197 649.884 533.404 651.216 535.337 C 648.972 537.935 649.559 536.855 649.781 541.041 C 648.625 542.443 646.723 544.6 645.74 546.01 C 646.386 549.274 648.115 551.617 651.038 553.304 C 652.512 556.081 653.414 557.445 653.932 560.619 L 650.995 562.042 L 644.396 565.871 L 642.43 560.131 C 639.39 561.108 636.153 563.199 635.077 566.182 C 631.634 575.722 624.94 571.122 616.976 570.452 C 614.475 571.95 614.725 571.271 613.868 573.452 C 612.22 575.296 610.252 577.659 608.431 579.259 C 605.992 577.198 605.823 576.859 604.919 573.787 C 603.768 574.368 602.968 574.96 601.72 574.829 C 591.035 562.899 594.23 557.117 600.019 543.195 C 601.665 542.474 604.774 541.992 606.647 541.626 C 606.487 539 607.476 536.805 608.321 534.267 C 607.79 532.612 606.35 531.752 604.95 530.593 L 609.177 527.95 C 612.761 527.258 613.811 526.563 617.015 524.887 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 617.015 524.887 C 623.473 523.896 629.431 523.4 635.846 525.096 L 643.09 529.072 C 645.46 530.197 649.884 533.404 651.216 535.337 C 648.972 537.935 649.559 536.855 649.781 541.041 C 648.625 542.443 646.723 544.6 645.74 546.01 L 645.072 546.086 C 642.19 545.234 641.465 545.064 638.832 543.44 C 636.929 544.86 634.932 546.152 633.476 547.995 C 632.837 550.891 631.28 551.797 629.034 553.677 L 628.255 553.877 C 625.054 559.755 621.856 559.419 615.693 560.728 C 610.713 556.654 609.336 548.183 606.647 541.626 C 606.487 539 607.476 536.805 608.321 534.267 C 607.79 532.612 606.35 531.752 604.95 530.593 L 609.177 527.95 C 612.761 527.258 613.811 526.563 617.015 524.887 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 608.321 534.267 L 610.985 536.03 C 611.811 537.459 613.191 540.504 614.376 541.015 C 617.354 541.738 618.665 541.353 619.207 544.861 C 620.938 540.722 618.697 541.139 622.189 538.144 L 625.015 539.681 C 626.484 541.171 632.133 547.089 633.476 547.995 C 632.837 550.891 631.28 551.797 629.034 553.677 L 628.255 553.877 C 625.054 559.755 621.856 559.419 615.693 560.728 C 610.713 556.654 609.336 548.183 606.647 541.626 C 606.487 539 607.476 536.805 608.321 534.267 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 619.207 544.861 C 620.938 540.722 618.697 541.139 622.189 538.144 L 625.015 539.681 C 626.484 541.171 632.133 547.089 633.476 547.995 C 632.837 550.891 631.28 551.797 629.034 553.677 L 628.255 553.877 C 624.286 548.611 620.535 553.029 619.207 544.861 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 614.376 541.015 C 617.354 541.738 618.665 541.353 619.207 544.861 C 620.535 553.029 624.286 548.611 628.255 553.877 C 625.054 559.755 621.856 559.419 615.693 560.728 C 617.94 555.752 617.845 545.721 614.376 541.015 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 643.09 529.072 C 645.46 530.197 649.884 533.404 651.216 535.337 C 648.972 537.935 649.559 536.855 649.781 541.041 C 648.625 542.443 646.723 544.6 645.74 546.01 L 645.072 546.086 C 642.19 545.234 641.465 545.064 638.832 543.44 C 637.12 540.304 635.812 538.581 636.082 535.035 C 637.117 532.125 640.348 530.718 643.09 529.072 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 635.846 525.096 L 643.09 529.072 C 640.348 530.718 637.117 532.125 636.082 535.035 C 635.812 538.581 637.12 540.304 638.832 543.44 C 636.929 544.86 634.932 546.152 633.476 547.995 C 632.133 547.089 626.484 541.171 625.015 539.681 C 626.975 537.182 627.656 535.877 630.78 535.004 C 634.264 531.716 634.571 529.618 635.846 525.096 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 617.015 524.887 C 623.473 523.896 629.431 523.4 635.846 525.096 C 634.571 529.618 634.264 531.716 630.78 535.004 C 624.679 531.931 621.948 529.348 617.015 524.887 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 613.973 532.955 C 617.728 534.442 619.787 534.913 622.189 538.144 C 618.697 541.139 620.938 540.722 619.207 544.861 C 618.665 541.353 617.354 541.738 614.376 541.015 C 613.191 540.504 611.811 537.459 610.985 536.03 L 613.973 532.955 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 604.95 530.593 L 609.177 527.95 C 610.671 529.818 612.058 532.24 613.973 532.955 L 610.985 536.03 L 608.321 534.267 C 607.79 532.612 606.35 531.752 604.95 530.593 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 600.019 543.195 C 601.665 542.474 604.774 541.992 606.647 541.626 C 609.336 548.183 610.713 556.654 615.693 560.728 L 615.045 563.02 C 617.065 564.831 616.343 564.269 619.75 564.426 C 615.392 566.583 614.08 564.192 610.545 566.914 C 607.642 569.673 606.844 570.295 604.919 573.787 C 603.768 574.368 602.968 574.96 601.72 574.829 C 591.035 562.899 594.23 557.117 600.019 543.195 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 629.034 553.677 C 632.814 555.456 631.517 554.344 633.827 558.056 C 629.291 564.75 627.476 563.651 619.75 564.426 C 616.343 564.269 617.065 564.831 615.045 563.02 L 615.693 560.728 C 621.856 559.419 625.054 559.755 628.255 553.877 L 629.034 553.677 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 638.832 543.44 C 641.465 545.064 642.19 545.234 645.072 546.086 C 643.203 552.281 636.973 553.771 633.827 558.056 C 631.517 554.344 632.814 555.456 629.034 553.677 C 631.28 551.797 632.837 550.891 633.476 547.995 C 634.932 546.152 636.929 544.86 638.832 543.44 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 651.038 553.304 C 652.512 556.081 653.414 557.445 653.932 560.619 L 650.995 562.042 L 644.396 565.871 L 642.43 560.131 C 644.617 559.09 648.965 555.068 651.038 553.304 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 610.545 566.914 C 612.99 569.498 613.072 570.155 613.868 573.452 C 612.22 575.296 610.252 577.659 608.431 579.259 C 605.992 577.198 605.823 576.859 604.919 573.787 C 606.844 570.295 607.642 569.673 610.545 566.914 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 613.868 573.452 C 614.725 571.271 614.475 571.95 616.976 570.452 L 615.51 573.667 L 617.489 575.749 C 620.464 579.77 621.746 582.022 626.332 584.201 C 627.407 585.964 629.065 588.901 630.262 590.435 C 632.526 591.911 632.43 591.333 633.166 593.367 C 632.322 597.255 631.179 603.549 629.673 607.021 L 633.103 610.138 L 631.263 615.231 C 629.449 617.155 629.394 617.348 626.912 617.884 L 622.025 614.378 C 617.909 610.26 617.256 608.01 615.732 602.521 C 611.921 598.134 610.888 597.376 605.928 594.316 C 607.678 591.771 610.474 591.986 612.969 588.032 C 609.757 584.452 608.208 583.547 608.431 579.259 C 610.252 577.659 612.22 575.296 613.868 573.452 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 613.868 573.452 C 614.725 571.271 614.475 571.95 616.976 570.452 L 615.51 573.667 L 617.489 575.749 C 620.464 579.77 621.746 582.022 626.332 584.201 C 622.875 584.336 623.046 583.715 621.155 585.74 C 620.034 589.102 620.597 589.869 616.958 591.167 C 615.289 590.927 614.696 590.618 613.177 590.003 L 612.969 588.032 C 609.757 584.452 608.208 583.547 608.431 579.259 C 610.252 577.659 612.22 575.296 613.868 573.452 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 617.489 575.749 C 620.464 579.77 621.746 582.022 626.332 584.201 C 622.875 584.336 623.046 583.715 621.155 585.74 C 618.792 584.317 618.306 583.762 616.38 581.766 C 615.83 578.105 615.831 579.385 617.489 575.749 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 617.645 597.303 C 621.116 593.493 620.465 593.038 624.731 591.747 C 628.24 594.664 626.591 600.571 623.934 603.42 C 622.018 603.109 622.324 603.44 621.023 602.358 C 618.556 601.07 616.487 600.533 615.754 597.977 L 617.645 597.303 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 612.969 588.032 L 613.177 590.003 C 614.696 590.618 615.289 590.927 616.958 591.167 L 614.437 592.738 L 614.078 594.384 C 615.579 596.071 615.674 596.291 617.645 597.303 L 615.754 597.977 C 616.487 600.533 618.556 601.07 621.023 602.358 L 615.732 602.521 C 611.921 598.134 610.888 597.376 605.928 594.316 C 607.678 591.771 610.474 591.986 612.969 588.032 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 629.673 607.021 L 633.103 610.138 L 631.263 615.231 C 629.449 617.155 629.394 617.348 626.912 617.884 L 622.025 614.378 C 625.774 611.48 626.701 610.673 629.673 607.021 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 656.974 559.013 C 657.134 557.312 657.287 557.142 657.898 555.6 C 660.961 554.48 666.177 557.139 670.361 558.162 C 672.852 558.805 673.981 559.01 676.257 560.312 C 682.157 566.235 688.48 574.333 694.265 579.448 L 692.928 581.291 C 688.069 581.511 688.878 580.708 685.92 583.372 C 685.055 586.581 685.338 587.886 685.653 591.101 L 683.183 589.101 L 681.677 592.131 C 678.302 590.144 677.828 590.015 674.892 592.036 L 673.593 585.413 C 671.17 581.658 672.377 582.811 668.031 581.071 L 666.568 577.033 C 665.798 575.203 665.338 573.023 664.849 571.076 C 664.708 570.451 664.561 569.828 664.409 569.206 C 662.952 563.25 662.851 562.84 658.106 559.972 L 656.974 559.013 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 673.593 585.413 C 673.732 581.214 673.554 578.355 675.169 574.495 L 679.314 573.824 C 682.998 575.886 683.212 575.251 684.931 578.417 L 684.619 582.701 L 683.183 589.101 L 681.677 592.131 C 678.302 590.144 677.828 590.015 674.892 592.036 L 673.593 585.413 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 679.314 573.824 C 682.998 575.886 683.212 575.251 684.931 578.417 L 684.619 582.701 C 681.497 581.618 679.114 581.044 676.651 578.835 L 676.585 576.688 L 679.314 573.824 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 666.568 577.033 L 668.031 581.071 C 672.377 582.811 671.17 581.658 673.593 585.413 L 674.892 592.036 C 672.624 596.005 673.785 595.121 670.052 597.345 L 670.099 597.752 C 668.448 598.709 667.457 599.096 666.322 600.651 L 665.376 601.67 C 663.84 603.398 661.897 605.728 660.306 607.311 C 657.866 605.825 655.31 603.903 652.963 602.231 L 651.432 596.953 C 647.246 595.51 648.6 596.55 646.123 593.202 C 646.713 590.26 647.706 587.261 648.587 584.381 L 651.062 583.663 L 652.056 584.259 C 655.695 583.126 655.126 579.631 656.122 575.877 L 662.608 576.755 L 666.568 577.033 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 656.122 575.877 L 662.608 576.755 C 663.784 582.337 664.368 589.304 657.116 591.077 L 653.02 593.503 L 651.432 596.953 C 647.246 595.51 648.6 596.55 646.123 593.202 C 646.713 590.26 647.706 587.261 648.587 584.381 L 651.062 583.663 L 652.056 584.259 C 655.695 583.126 655.126 579.631 656.122 575.877 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 651.062 583.663 L 652.056 584.259 C 652.831 587.876 652.875 589.798 653.02 593.503 L 651.432 596.953 C 647.246 595.51 648.6 596.55 646.123 593.202 C 646.713 590.26 647.706 587.261 648.587 584.381 L 651.062 583.663 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 657.116 591.077 C 660.656 593.241 662.283 597.793 665.376 601.67 C 663.84 603.398 661.897 605.728 660.306 607.311 C 657.866 605.825 655.31 603.903 652.963 602.231 L 651.432 596.953 L 653.02 593.503 L 657.116 591.077 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 668.031 581.071 C 672.377 582.811 671.17 581.658 673.593 585.413 L 674.892 592.036 C 672.624 596.005 673.785 595.121 670.052 597.345 C 667.157 596.583 668.201 597.198 666.128 594.956 C 665.247 590.749 666.997 585.391 668.031 581.071 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 653.932 560.619 L 655.687 563.927 L 658.106 559.972 C 662.851 562.84 662.952 563.25 664.409 569.206 C 664.561 569.828 664.708 570.451 664.849 571.076 C 665.338 573.023 665.798 575.203 666.568 577.033 L 662.608 576.755 L 656.122 575.877 C 655.126 579.631 655.695 583.126 652.056 584.259 L 651.062 583.663 L 648.587 584.381 C 647.615 582.766 644.158 581.117 641.54 577.897 L 642.473 574.528 L 637.857 572.159 C 638.579 570.943 643.112 567.004 644.396 565.871 L 650.995 562.042 L 653.932 560.619 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 642.473 574.528 C 645.259 573.209 646.498 573.415 649.498 573.473 C 653.077 576.149 651.59 575.445 656.122 575.877 C 655.126 579.631 655.695 583.126 652.056 584.259 L 651.062 583.663 L 648.587 584.381 C 647.615 582.766 644.158 581.117 641.54 577.897 L 642.473 574.528 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 649.498 573.473 C 653.077 576.149 651.59 575.445 656.122 575.877 C 655.126 579.631 655.695 583.126 652.056 584.259 L 651.062 583.663 C 648.778 580.667 649.378 577.326 649.498 573.473 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 658.106 559.972 C 662.851 562.84 662.952 563.25 664.409 569.206 C 664.561 569.828 664.708 570.451 664.849 571.076 C 662.475 574.548 663.763 573.473 660.018 575.217 C 655.867 574.466 657.286 575.313 654.676 572.09 C 655.792 568.456 655.716 567.66 655.687 563.927 L 658.106 559.972 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 653.932 560.619 L 655.687 563.927 C 655.716 567.66 655.792 568.456 654.676 572.09 C 652.766 569.863 652.846 566.119 650.995 562.042 L 653.932 560.619 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 646.123 593.202 C 648.6 596.55 647.246 595.51 651.432 596.953 L 652.963 602.231 C 652.985 606.288 652.701 609.396 654.265 613.118 C 651.786 617.028 651.697 619.242 651.979 623.978 L 649.46 624.832 C 647.44 626.521 646.079 627.913 643.629 628.878 C 640.755 623.922 639.656 623.156 639.893 617.602 L 643.818 613.178 L 645.664 609.755 C 647.54 605.354 644.445 600.653 646.123 593.202 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 643.818 613.178 C 648.122 616.242 648.277 619.362 649.46 624.832 C 647.44 626.521 646.079 627.913 643.629 628.878 C 640.755 623.922 639.656 623.156 639.893 617.602 L 643.818 613.178 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 633.166 593.367 L 635.38 594.017 L 634.625 596.984 C 637.603 601.477 642.297 599.735 643.84 603.434 L 642.635 604.993 C 643.947 606.616 645.132 607.759 645.664 609.755 L 643.818 613.178 L 639.893 617.602 C 639.217 617.61 638.541 617.613 637.865 617.612 C 632.951 617.581 634.019 618.036 631.263 615.231 L 633.103 610.138 L 629.673 607.021 C 631.179 603.549 632.322 597.255 633.166 593.367 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 642.635 604.993 C 643.947 606.616 645.132 607.759 645.664 609.755 L 643.818 613.178 L 639.893 617.602 C 639.217 617.61 638.541 617.613 637.865 617.612 C 632.951 617.581 634.019 618.036 631.263 615.231 L 633.103 610.138 C 636.859 607.285 638.219 606.797 642.635 604.993 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 641.54 577.897 C 644.158 581.117 647.615 582.766 648.587 584.381 C 647.706 587.261 646.713 590.26 646.123 593.202 C 644.445 600.653 647.54 605.354 645.664 609.755 C 645.132 607.759 643.947 606.616 642.635 604.993 L 643.84 603.434 C 642.297 599.735 637.603 601.477 634.625 596.984 L 635.38 594.017 L 633.166 593.367 C 632.43 591.333 632.526 591.911 630.262 590.435 L 633.008 588.432 C 636.764 591.873 636.474 592.758 640.513 593.019 C 641.164 592.076 641.732 591.649 641.701 590.736 C 642.405 589.829 642.612 588.63 642.252 587.54 C 641.892 586.449 641.013 585.609 639.908 585.299 C 638.053 583.948 637.35 585.944 633.749 582.732 L 638.15 583 C 641.106 581.312 640.209 581.905 641.54 577.897 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 637.857 572.159 L 642.473 574.528 L 641.54 577.897 C 640.209 581.905 641.106 581.312 638.15 583 L 633.749 582.732 C 633.066 582.479 632.386 582.214 631.711 581.937 C 628.413 580.549 628.324 580.562 627.297 578.071 C 629.88 575.438 633.675 575.898 637.857 572.159 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 639.908 585.299 C 641.013 585.609 641.892 586.449 642.252 587.54 C 642.612 588.63 642.405 589.829 641.701 590.736 C 640.638 592.104 638.724 592.472 637.23 591.593 C 635.735 590.715 635.125 588.865 635.804 587.27 C 636.482 585.675 638.239 584.832 639.908 585.299 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 737.219 468.756 C 737.918 468.246 739.77 467.71 740.692 467.39 C 746.337 474.169 754.791 473.635 762.756 472.069 C 762.979 476.366 762.228 480.046 761.316 484.204 C 759.03 494.623 758.008 505.098 757.538 515.74 C 754.942 524.506 748.943 531.708 747.945 537.588 C 747.044 541.659 746.189 542.586 743.724 545.993 C 739.159 553.42 730.717 564.57 727.529 571.201 L 727.009 574.146 C 726.093 575.454 725.126 576.114 723.899 577.117 L 722.084 576.635 C 719.806 573.674 719.2 567.619 718.498 563.715 C 721.542 552.64 712.229 550.822 720.036 541.456 L 716.542 536.968 L 721.095 531.637 L 719.277 528.004 L 722.765 521.566 C 722.319 517.95 721.239 518.216 721.093 511.902 C 719.777 510.72 718.872 509.171 717.839 507.687 C 715.403 508.274 716.495 508.46 714.434 507.282 L 713.675 501.485 C 714.367 494.529 713.466 483.001 714.011 474.244 C 713.647 472.942 713.846 472.345 713.952 470.957 C 717.067 469.681 717.339 470.405 721.27 471.139 L 724.453 469.525 L 727.836 472.008 L 728.709 470.241 C 731.22 468.297 733.867 468.761 737.219 468.756 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 737.219 468.756 C 737.918 468.246 739.77 467.71 740.692 467.39 C 746.337 474.169 754.791 473.635 762.756 472.069 C 762.979 476.366 762.228 480.046 761.316 484.204 C 759.03 494.623 758.008 505.098 757.538 515.74 L 757.082 511.764 C 754.757 508.46 754.192 508.866 749.472 507.689 C 747.469 504.124 748.296 503.78 748.609 498.926 C 749.6 497.415 750.565 495.887 751.505 494.344 C 753.625 490.839 754.352 489.678 753.334 485.609 C 747.612 487.308 729.715 498.597 726.729 503.002 L 726.118 502.837 C 724.49 505.898 722.917 508.954 721.093 511.902 C 719.777 510.72 718.872 509.171 717.839 507.687 C 715.403 508.274 716.495 508.46 714.434 507.282 L 713.675 501.485 C 714.367 494.529 713.466 483.001 714.011 474.244 C 713.647 472.942 713.846 472.345 713.952 470.957 C 717.067 469.681 717.339 470.405 721.27 471.139 L 724.453 469.525 L 727.836 472.008 L 728.709 470.241 C 731.22 468.297 733.867 468.761 737.219 468.756 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 737.219 468.756 C 737.918 468.246 739.77 467.71 740.692 467.39 C 746.337 474.169 754.791 473.635 762.756 472.069 C 762.979 476.366 762.228 480.046 761.316 484.204 C 759.03 494.623 758.008 505.098 757.538 515.74 L 757.082 511.764 C 754.757 508.46 754.192 508.866 749.472 507.689 C 747.469 504.124 748.296 503.78 748.609 498.926 C 749.6 497.415 750.565 495.887 751.505 494.344 C 753.625 490.839 754.352 489.678 753.334 485.609 L 750.417 483.312 C 749.476 476.448 741.013 475.344 736.047 478.196 C 726.656 482.487 732.504 476.618 722.224 476.452 L 720.593 474.167 L 721.27 471.139 L 724.453 469.525 L 727.836 472.008 L 728.709 470.241 C 731.22 468.297 733.867 468.761 737.219 468.756 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 727.836 472.008 L 728.709 470.241 C 731.22 468.297 733.867 468.761 737.219 468.756 C 734.965 470.979 733.155 473.333 730.026 473.526 C 729.425 473.136 728.3 472.471 727.836 472.008 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 736.047 478.196 C 741.013 475.344 749.476 476.448 750.417 483.312 L 753.334 485.609 C 747.612 487.308 729.715 498.597 726.729 503.002 L 726.118 502.837 C 724.49 505.898 722.917 508.954 721.093 511.902 C 719.777 510.72 718.872 509.171 717.839 507.687 L 722.789 499.95 C 722.98 497.205 723.173 496.513 721.866 494.14 L 725.241 491.791 C 728.102 492.167 730.288 490.337 732.948 488.766 C 730.615 486.545 731.163 487.63 730.61 484.727 C 732.322 482.035 733.878 480.505 736.047 478.196 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 736.047 478.196 C 741.013 475.344 749.476 476.448 750.417 483.312 C 743.236 484.749 739.694 485.536 732.948 488.766 C 730.615 486.545 731.163 487.63 730.61 484.727 C 732.322 482.035 733.878 480.505 736.047 478.196 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 714.011 474.244 C 723.48 480.154 717.072 486.036 721.866 494.14 C 723.173 496.513 722.98 497.205 722.789 499.95 L 717.839 507.687 C 715.403 508.274 716.495 508.46 714.434 507.282 L 713.675 501.485 C 714.367 494.529 713.466 483.001 714.011 474.244 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 722.224 476.452 C 732.504 476.618 726.656 482.487 736.047 478.196 C 733.878 480.505 732.322 482.035 730.61 484.727 C 731.163 487.63 730.615 486.545 732.948 488.766 C 730.288 490.337 728.102 492.167 725.241 491.791 C 725.163 485.307 724.123 482.587 722.224 476.452 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 720.036 541.456 C 724.079 538.336 727.129 532.47 731.521 531.356 C 734.169 532.457 733.311 532.051 734.987 535.314 C 736.406 547.785 738.785 540.723 743.724 545.993 C 739.159 553.42 730.717 564.57 727.529 571.201 L 727.009 574.146 C 726.093 575.454 725.126 576.114 723.899 577.117 L 722.084 576.635 C 719.806 573.674 719.2 567.619 718.498 563.715 C 721.542 552.64 712.229 550.822 720.036 541.456 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 734.987 535.314 C 736.406 547.785 738.785 540.723 743.724 545.993 C 739.159 553.42 730.717 564.57 727.529 571.201 C 727.539 570.704 734.744 535.936 734.987 535.314 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 726.118 502.837 L 726.729 503.002 C 728.446 506.533 730.704 509.233 731.755 512.744 C 733.172 515.991 735.702 522.312 737.289 525.199 L 738.211 527.093 C 739.219 530.235 740.351 534.522 742.183 537.131 L 747.945 537.588 C 747.044 541.659 746.189 542.586 743.724 545.993 C 738.785 540.723 736.406 547.785 734.987 535.314 C 733.311 532.051 734.169 532.457 731.521 531.356 C 727.129 532.47 724.079 538.336 720.036 541.456 L 716.542 536.968 L 721.095 531.637 L 719.277 528.004 L 722.765 521.566 C 722.319 517.95 721.239 518.216 721.093 511.902 C 722.917 508.954 724.49 505.898 726.118 502.837 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 726.118 502.837 L 726.729 503.002 C 728.446 506.533 730.704 509.233 731.755 512.744 C 728.508 515.981 728.432 516.614 727.322 521.093 L 722.765 521.566 C 722.319 517.95 721.239 518.216 721.093 511.902 C 722.917 508.954 724.49 505.898 726.118 502.837 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 722.765 521.566 L 727.322 521.093 L 729.366 525.634 C 727.799 529.338 725.338 529.024 721.095 531.637 L 719.277 528.004 L 722.765 521.566 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 742.89 519.682 C 750.142 518.466 751.698 516.913 757.082 511.764 L 757.538 515.74 C 754.942 524.506 748.943 531.708 747.945 537.588 L 742.183 537.131 C 740.351 534.522 739.219 530.235 738.211 527.093 L 737.289 525.199 C 739.069 523.252 740.978 521.498 742.89 519.682 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 738.211 527.093 C 740.159 525.004 739.085 525.565 742.716 525.433 C 745.826 527.4 744.768 526.321 746.554 529.91 C 746.381 533.968 746.896 532.576 745.003 535.719 L 742.183 537.131 C 740.351 534.522 739.219 530.235 738.211 527.093 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 742.89 519.682 C 742.537 509.371 741.944 506.554 748.609 498.926 C 748.296 503.78 747.469 504.124 749.472 507.689 C 754.192 508.866 754.757 508.46 757.082 511.764 C 751.698 516.913 750.142 518.466 742.89 519.682 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 650.948 425.377 C 653.594 421.678 654.242 421.705 658.566 420.671 C 666.364 418.807 670.263 427.376 673.881 432.714 C 681.743 432.83 718.859 432.022 723.12 432.816 L 716.355 433.409 C 714.07 436.256 716.382 436.087 714.501 438.9 L 713.752 440.096 C 717.949 440.218 722.605 440.978 727.034 441.246 C 731.343 434.103 732.076 435.814 740.373 435.33 L 741.384 433.996 C 740.333 432.395 739.633 431.228 738.734 429.537 L 739.299 428.495 C 742.355 426.377 746.877 426.724 750.717 426.623 L 752.996 427.512 C 761.059 431.43 762.451 431.8 771.105 434.293 C 766.551 439.2 756.069 442.329 749.731 444.419 C 747.471 445.279 740.13 448.238 738.169 448.565 L 733.24 449.514 C 713.89 452.192 695.759 450.987 677.067 453.083 C 666.645 454.253 654.363 474.305 640.655 465.327 C 630.305 460.036 632.13 444.405 632.207 434.593 L 638.711 427.651 C 643.15 427.027 646.572 426.36 650.948 425.377 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 750.717 426.623 L 752.996 427.512 C 761.059 431.43 762.451 431.8 771.105 434.293 C 766.551 439.2 756.069 442.329 749.731 444.419 C 747.471 445.279 740.13 448.238 738.169 448.565 L 733.24 449.514 C 713.89 452.192 695.759 450.987 677.067 453.083 C 666.645 454.253 654.363 474.305 640.655 465.327 L 641.411 464.389 C 639.878 458.964 643.244 458.575 647.123 455.288 C 649.042 453.553 651.389 453.192 653.948 452.471 L 660.332 453.008 C 661.246 452.951 664.324 452.237 665.401 452.014 C 672.476 450.048 679.573 448.162 686.69 446.357 C 690.902 446.204 709.792 442.58 713.121 440.02 L 713.752 440.096 C 717.949 440.218 722.605 440.978 727.034 441.246 C 731.343 434.103 732.076 435.814 740.373 435.33 L 741.384 433.996 C 740.333 432.395 739.633 431.228 738.734 429.537 L 739.299 428.495 C 742.355 426.377 746.877 426.724 750.717 426.623 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 653.948 452.471 L 660.332 453.008 C 649.06 455.797 657.567 463.568 641.411 464.389 C 639.878 458.964 643.244 458.575 647.123 455.288 C 649.042 453.553 651.389 453.192 653.948 452.471 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 650.948 425.377 C 653.594 421.678 654.242 421.705 658.566 420.671 C 666.364 418.807 670.263 427.376 673.881 432.714 C 681.743 432.83 718.859 432.022 723.12 432.816 L 716.355 433.409 C 714.07 436.256 716.382 436.087 714.501 438.9 L 713.752 440.096 L 713.121 440.02 C 709.792 442.58 690.902 446.204 686.69 446.357 C 689.921 442.454 690.067 446.012 692.497 440.48 C 690.543 438.466 686.138 438.091 682.761 437.183 C 666.39 438.647 669.422 427.89 660.468 423.522 C 656.617 423.838 657.274 424.333 653.735 427.586 L 650.948 425.377 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 682.761 437.183 C 690.464 434.925 704.155 434.419 711.8 437.713 L 713.121 440.02 C 709.792 442.58 690.902 446.204 686.69 446.357 C 689.921 442.454 690.067 446.012 692.497 440.48 C 690.543 438.466 686.138 438.091 682.761 437.183 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 650.948 425.377 L 653.735 427.586 C 643.133 437.897 638.633 428.322 634.788 435.601 C 635.495 439.141 634.899 438.982 637.072 441.206 C 638.403 441.112 639.838 441.078 641.12 440.761 L 648.827 438.069 C 649.199 440.086 649.629 443.413 650.556 445.097 L 651.401 445.655 L 653.948 452.471 C 651.389 453.192 649.042 453.553 647.123 455.288 C 643.244 458.575 639.878 458.964 641.411 464.389 L 640.655 465.327 C 630.305 460.036 632.13 444.405 632.207 434.593 L 638.711 427.651 C 643.15 427.027 646.572 426.36 650.948 425.377 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 641.12 440.761 L 648.827 438.069 C 649.199 440.086 649.629 443.413 650.556 445.097 L 651.401 445.655 L 653.948 452.471 C 651.389 453.192 649.042 453.553 647.123 455.288 L 646.987 451.291 L 646.686 448.643 L 644.086 446.801 C 644.085 444.669 644.008 443.893 643.687 441.784 L 641.12 440.761 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 644.086 446.801 L 646.686 448.643 L 646.987 451.291 C 642.734 452.323 638.695 454.012 634.97 451.085 L 635.234 449.322 C 637.74 447.017 640.652 447.139 644.086 446.801 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 648.827 438.069 L 656.379 438.003 C 660.529 440.271 663.234 443.431 662.742 448.591 C 663.437 449.462 664.866 451.158 665.401 452.014 C 664.324 452.237 661.246 452.951 660.332 453.008 L 653.948 452.471 L 651.401 445.655 L 650.556 445.097 C 649.629 443.413 649.199 440.086 648.827 438.069 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 651.401 445.655 L 652.838 443.626 C 658.004 443.824 656.898 447.272 662.742 448.591 C 663.437 449.462 664.866 451.158 665.401 452.014 C 664.324 452.237 661.246 452.951 660.332 453.008 L 653.948 452.471 L 651.401 445.655 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 648.827 438.069 L 656.379 438.003 C 654.629 441.018 654.162 441.272 651.275 442.987 L 650.556 445.097 C 649.629 443.413 649.199 440.086 648.827 438.069 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 913.504 488.494 C 914.976 488.275 916.485 487.445 917.893 486.805 L 918.481 494.308 C 919.238 496.803 918.63 496.072 920.563 497.402 C 928.791 494.902 940.224 493.602 947.205 490.099 L 947.672 489.979 C 951.116 487.94 953.101 487.968 957.071 487.534 C 958.689 488.186 960.511 488.657 961.547 489.872 C 970.025 495.301 966.728 488.225 977.113 492.661 L 979.537 493.47 C 985.172 498.192 985.4 500.18 982.558 506.991 C 972.546 511.63 974.732 502.965 961.993 513.397 L 958.408 513.835 L 957.346 515.258 C 954.259 519.038 953.693 519.64 949.44 521.949 C 948.144 522.958 946.047 524.429 945.08 525.625 C 943.465 526.815 940.785 528.924 939.141 529.854 C 936.549 531.318 933.514 533.293 930.701 533.968 C 928.05 534.471 927.393 534.481 925.277 536.106 C 917.709 540.847 915.713 539.344 908.668 534.871 C 902.291 528.836 884.097 516.323 875.942 510.176 C 878.086 505.51 883.331 506.369 888.025 506.077 C 894.12 504.274 896.173 502.84 900.611 498.397 C 903.661 495.026 905.369 492.515 907.959 488.753 L 908.489 490.002 L 913.504 488.494 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 947.672 489.979 C 951.116 487.94 953.101 487.968 957.071 487.534 C 958.689 488.186 960.511 488.657 961.547 489.872 C 970.025 495.301 966.728 488.225 977.113 492.661 L 979.537 493.47 C 985.172 498.192 985.4 500.18 982.558 506.991 C 972.546 511.63 974.732 502.965 961.993 513.397 L 958.408 513.835 L 957.346 515.258 C 954.259 519.038 953.693 519.64 949.44 521.949 C 944.112 520.491 943.143 520.636 942.525 515.088 L 944.917 508.876 C 946.08 505.885 946.213 505.412 948.95 503.812 C 946.519 501.878 947.447 500.415 944.399 497.484 L 941.187 497.958 C 942.821 494.345 945.167 496.717 947.205 490.099 L 947.672 489.979 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 947.672 489.979 C 951.116 487.94 953.101 487.968 957.071 487.534 C 958.689 488.186 960.511 488.657 961.547 489.872 C 970.025 495.301 966.728 488.225 977.113 492.661 C 962.962 496.408 970.879 501.272 965.078 504.795 C 961.106 503.622 960.406 502.012 958.698 498.354 L 956.837 497.175 C 953.613 500.562 952.861 501.286 948.95 503.812 C 946.519 501.878 947.447 500.415 944.399 497.484 L 941.187 497.958 C 942.821 494.345 945.167 496.717 947.205 490.099 L 947.672 489.979 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 947.672 489.979 C 951.116 487.94 953.101 487.968 957.071 487.534 L 957.499 493.538 L 959.419 493.361 L 960.223 494.445 C 958.969 495.56 958.2 496.194 956.837 497.175 C 953.613 500.562 952.861 501.286 948.95 503.812 C 946.519 501.878 947.447 500.415 944.399 497.484 L 941.187 497.958 C 942.821 494.345 945.167 496.717 947.205 490.099 L 947.672 489.979 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 947.672 489.979 C 951.116 487.94 953.101 487.968 957.071 487.534 L 957.499 493.538 C 955.38 493.963 953.056 493.959 950.879 494.047 C 948.019 492.472 948.928 493.477 947.672 489.979 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 900.611 498.397 L 903.008 500.559 L 902.983 501.397 C 902.66 517.131 912.604 515.809 918.8 523.45 L 919.49 526.339 L 915.009 531.824 C 913.077 532.916 910.768 534.439 908.668 534.871 C 902.291 528.836 884.097 516.323 875.942 510.176 C 878.086 505.51 883.331 506.369 888.025 506.077 C 894.12 504.274 896.173 502.84 900.611 498.397 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 913.504 488.494 C 914.976 488.275 916.485 487.445 917.893 486.805 L 918.481 494.308 C 917.072 495.558 916.666 496.072 914.866 496.763 C 915.953 498.432 917.831 501.123 918.677 502.807 C 918.209 503.004 914.912 504.426 914.659 504.437 C 915.28 510.396 917.09 513.097 920.448 518.081 C 919.199 520.421 918.913 520.822 918.8 523.45 C 912.604 515.809 902.66 517.131 902.983 501.397 L 903.008 500.559 L 900.611 498.397 C 903.661 495.026 905.369 492.515 907.959 488.753 L 908.489 490.002 L 913.504 488.494 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 913.504 488.494 C 914.976 488.275 916.485 487.445 917.893 486.805 L 918.481 494.308 C 917.072 495.558 916.666 496.072 914.866 496.763 L 914.247 497.06 C 911.692 500.305 913.102 499.471 908.95 500.132 C 906.962 499.507 907.654 499.856 906.193 498.023 C 906.091 494.554 907.012 493.135 908.489 490.002 L 913.504 488.494 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 913.504 488.494 C 914.976 488.275 916.485 487.445 917.893 486.805 L 918.481 494.308 C 917.072 495.558 916.666 496.072 914.866 496.763 L 914.247 497.06 L 913.504 488.494 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 914.247 497.06 L 914.866 496.763 C 915.953 498.432 917.831 501.123 918.677 502.807 C 918.209 503.004 914.912 504.426 914.659 504.437 C 912.407 501.817 912.088 501.577 908.95 500.132 C 913.102 499.471 911.692 500.305 914.247 497.06 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 923.949 515.174 L 926.796 516.935 C 926.869 519.776 926.652 522.879 927.791 525.377 C 929.823 525.614 931.694 525.656 933.511 526.539 C 934.996 527.486 937.607 529.282 939.141 529.854 C 936.549 531.318 933.514 533.293 930.701 533.968 C 928.05 534.471 927.393 534.481 925.277 536.106 C 917.709 540.847 915.713 539.344 908.668 534.871 C 910.768 534.439 913.077 532.916 915.009 531.824 L 919.49 526.339 L 918.8 523.45 C 918.913 520.822 919.199 520.421 920.448 518.081 C 922.305 517.247 922.559 516.739 923.949 515.174 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 919.49 526.339 C 921.316 527.004 923.28 527.625 924.954 528.586 C 925.206 532.12 924.613 530.965 926.949 533.074 L 930.701 533.968 C 928.05 534.471 927.393 534.481 925.277 536.106 C 917.709 540.847 915.713 539.344 908.668 534.871 C 910.768 534.439 913.077 532.916 915.009 531.824 L 919.49 526.339 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 908.668 534.871 C 910.768 534.439 913.077 532.916 915.009 531.824 C 919.39 534.49 920.281 534.839 925.277 536.106 C 917.709 540.847 915.713 539.344 908.668 534.871 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 947.205 490.099 C 945.167 496.717 942.821 494.345 941.187 497.958 L 939.132 501.898 C 937.616 503.531 937.234 504.275 935.182 504.824 L 929.103 503.607 L 925.377 501.837 C 924.017 505.098 924.943 504.125 922.023 505.644 C 919.428 504.738 920.346 505.409 918.677 502.807 C 917.831 501.123 915.953 498.432 914.866 496.763 C 916.666 496.072 917.072 495.558 918.481 494.308 C 919.238 496.803 918.63 496.072 920.563 497.402 C 928.791 494.902 940.224 493.602 947.205 490.099 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 925.377 501.837 C 928.156 497.614 934.186 497.935 938.278 500.166 L 939.132 501.898 C 937.616 503.531 937.234 504.275 935.182 504.824 L 929.103 503.607 L 925.377 501.837 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 925.377 501.837 L 929.103 503.607 C 932.094 507.504 932.704 508.232 933.313 513.033 C 930.582 511.625 928.043 510.529 925.231 509.311 L 923.955 510.151 L 923.949 515.174 C 922.559 516.739 922.305 517.247 920.448 518.081 C 917.09 513.097 915.28 510.396 914.659 504.437 C 914.912 504.426 918.209 503.004 918.677 502.807 C 920.346 505.409 919.428 504.738 922.023 505.644 C 924.943 504.125 924.017 505.098 925.377 501.837 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 926.796 516.935 C 929.969 514.843 929.287 514.596 932.536 514.643 C 933.627 516.133 934.063 516.481 934.265 518.345 L 936.253 522.277 L 933.511 526.539 C 931.694 525.656 929.823 525.614 927.791 525.377 C 926.652 522.879 926.869 519.776 926.796 516.935 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 941.187 497.958 L 944.399 497.484 C 947.447 500.415 946.519 501.878 948.95 503.812 C 946.213 505.412 946.08 505.885 944.917 508.876 L 943.446 507.588 L 940.399 508.278 L 939.384 505.856 L 935.182 504.824 C 937.234 504.275 937.616 503.531 939.132 501.898 L 941.187 497.958 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 936.253 522.277 C 939.362 523.162 942.436 523.797 945.08 525.625 C 943.465 526.815 940.785 528.924 939.141 529.854 C 937.607 529.282 934.996 527.486 933.511 526.539 L 936.253 522.277 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 940.399 508.278 L 943.446 507.588 L 944.917 508.876 L 942.525 515.088 L 939.43 511.942 L 940.399 508.278 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 994.752 483.641 C 995.91 483.675 999.475 483.228 1000.82 483.094 L 1002.17 483.237 L 1007.66 484.328 L 1008.61 485.754 L 1005.38 492.134 C 1013.31 503.138 1011 530.26 1010.9 544.077 C 1010.88 547.143 1009.2 549.496 1007.23 551.701 C 1005.25 549.662 1005.03 549.174 1002.45 548.257 C 995.087 557.304 990.324 554.304 987.611 558.01 L 985.095 554.753 C 981.669 551.519 978.483 544.288 976.223 539.852 C 973.63 532.649 975.221 532.245 973.601 526.112 C 970.445 523.385 965.776 524.221 960.984 524.184 C 960.044 520.831 959.522 517.972 957.346 515.258 L 958.408 513.835 L 961.993 513.397 C 974.732 502.965 972.546 511.63 982.558 506.991 C 985.4 500.18 985.172 498.192 979.537 493.47 C 981.234 493.063 987.715 490.698 988.972 489.223 L 989.753 489.112 L 992.659 486.108 L 994.752 483.641 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 992.659 486.108 C 995.544 488.533 995.48 491.041 996.665 495.173 C 996.005 498.916 994.508 505.543 995.013 509.05 L 989.045 518.307 C 988.544 521.283 988.439 523.759 984.839 524.841 C 983.36 528.218 982.972 528.87 982.878 532.621 C 979.308 535.491 978.83 536.071 976.223 539.852 C 973.63 532.649 975.221 532.245 973.601 526.112 C 970.445 523.385 965.776 524.221 960.984 524.184 C 960.044 520.831 959.522 517.972 957.346 515.258 L 958.408 513.835 L 961.993 513.397 C 974.732 502.965 972.546 511.63 982.558 506.991 C 985.4 500.18 985.172 498.192 979.537 493.47 C 981.234 493.063 987.715 490.698 988.972 489.223 L 989.753 489.112 L 992.659 486.108 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 982.558 506.991 L 985.879 508.178 C 983.392 513.408 982.685 514.946 978.919 519.292 C 975.18 519.625 968.482 519.993 965.115 521.025 C 963.824 522.132 962.471 523.416 960.984 524.184 C 960.044 520.831 959.522 517.972 957.346 515.258 L 958.408 513.835 L 961.993 513.397 C 974.732 502.965 972.546 511.63 982.558 506.991 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 957.346 515.258 L 958.408 513.835 L 961.993 513.397 C 962.961 516.774 963.186 518.051 965.115 521.025 C 963.824 522.132 962.471 523.416 960.984 524.184 C 960.044 520.831 959.522 517.972 957.346 515.258 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 988.972 489.223 L 989.753 489.112 C 991.451 497.543 993.535 502.242 985.879 508.178 L 982.558 506.991 C 985.4 500.18 985.172 498.192 979.537 493.47 C 981.234 493.063 987.715 490.698 988.972 489.223 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 994.752 483.641 C 995.91 483.675 999.475 483.228 1000.82 483.094 L 1002.17 483.237 L 1007.66 484.328 L 1008.61 485.754 L 1005.38 492.134 C 1003.03 499.744 1002.39 502.572 1001.26 510.65 C 999.72 517.184 999.414 521.682 998.843 528.347 L 993.814 526.64 L 994.6 525.849 C 991.234 523.918 990.296 521.682 989.045 518.307 L 995.013 509.05 C 994.508 505.543 996.005 498.916 996.665 495.173 C 995.48 491.041 995.544 488.533 992.659 486.108 L 994.752 483.641 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 995.013 509.05 C 997.333 513.798 995.475 519.949 994.6 525.849 C 991.234 523.918 990.296 521.682 989.045 518.307 L 995.013 509.05 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 994.752 483.641 C 995.91 483.675 999.475 483.228 1000.82 483.094 C 998.63 488.114 1000.75 491.394 998.257 496.062 L 997.144 496.004 L 996.665 495.173 C 995.48 491.041 995.544 488.533 992.659 486.108 L 994.752 483.641 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 1005.38 492.134 C 1013.31 503.138 1011 530.26 1010.9 544.077 C 1010.88 547.143 1009.2 549.496 1007.23 551.701 C 1005.25 549.662 1005.03 549.174 1002.45 548.257 C 1007.01 540.506 1006.07 522.496 1005.02 513.545 C 1004.81 511.717 1002.67 511.258 1001.26 510.65 C 1002.39 502.572 1003.03 499.744 1005.38 492.134 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 976.223 539.852 C 978.83 536.071 979.308 535.491 982.878 532.621 C 984.854 539.307 987.397 547.808 985.095 554.753 C 981.669 551.519 978.483 544.288 976.223 539.852 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 993.814 526.64 L 998.843 528.347 C 999.692 535.583 1000.64 537.635 996.543 543.759 L 994.472 544.52 C 992.15 541.57 992.547 542.205 992.97 537.602 C 992.957 533.088 992.864 531.011 993.814 526.64 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 705.042 538.969 C 704.987 536.646 705.851 520.903 706.955 519.447 L 709.837 520.372 C 710.981 522.436 713.508 527.409 715.03 528.769 C 716.639 528.774 717.734 528.399 719.277 528.004 L 721.095 531.637 L 716.542 536.968 L 720.036 541.456 C 712.229 550.822 721.542 552.64 718.498 563.715 C 719.2 567.619 719.806 573.674 722.084 576.635 L 723.899 577.117 C 725.126 576.114 726.093 575.454 727.009 574.146 C 727.259 578.408 726.543 579.083 728.747 581.753 L 731.082 582.41 L 731.176 582.867 L 726.53 584.639 L 724.612 585.739 C 724.581 587.228 724.594 588.346 724.344 589.834 C 719.434 590.443 720.625 586.8 719.083 588.443 L 720.857 594.847 L 717.039 596.392 C 714.699 595.955 710.247 596.037 707.712 596.011 C 705.433 596.183 701.934 596.066 699.544 596.052 L 698.71 597.083 L 694.432 600.95 C 691.789 598.275 688.178 593.989 685.653 591.101 C 685.338 587.886 685.055 586.581 685.92 583.372 C 688.878 580.708 688.069 581.511 692.928 581.291 L 694.265 579.448 C 688.48 574.333 682.157 566.235 676.257 560.312 C 673.981 559.01 672.852 558.805 670.361 558.162 C 668.342 556.122 667.781 554.419 666.58 551.831 C 673.553 552.607 678.331 549.789 684.872 547.133 C 692.8 548.97 697.32 551.24 704.153 555.199 C 704.009 550.585 703.442 544.335 704.737 540.071 L 705.042 538.969 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 712.227 557.729 C 714.451 552.517 711.382 556.551 713.697 550.91 C 718.039 553.171 716.495 561.262 718.498 563.715 C 719.2 567.619 719.806 573.674 722.084 576.635 L 723.899 577.117 C 725.126 576.114 726.093 575.454 727.009 574.146 C 727.259 578.408 726.543 579.083 728.747 581.753 L 731.082 582.41 L 731.176 582.867 L 726.53 584.639 L 724.612 585.739 C 724.581 587.228 724.594 588.346 724.344 589.834 C 719.434 590.443 720.625 586.8 719.083 588.443 L 720.857 594.847 L 717.039 596.392 C 714.699 595.955 710.247 596.037 707.712 596.011 C 705.433 596.183 701.934 596.066 699.544 596.052 L 698.71 597.083 L 694.432 600.95 C 691.789 598.275 688.178 593.989 685.653 591.101 C 685.338 587.886 685.055 586.581 685.92 583.372 C 688.878 580.708 688.069 581.511 692.928 581.291 L 694.265 579.448 C 697.769 580.081 703.619 581.332 706.975 581.357 L 707.283 580.153 C 708.439 575.407 708.601 571.034 706.968 566.425 C 706.161 563.281 706.077 562.855 706.52 559.65 C 709.256 557.453 708.333 558.084 712.227 557.729 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 712.227 557.729 C 714.451 552.517 711.382 556.551 713.697 550.91 C 718.039 553.171 716.495 561.262 718.498 563.715 C 719.2 567.619 719.806 573.674 722.084 576.635 L 723.899 577.117 C 725.126 576.114 726.093 575.454 727.009 574.146 C 727.259 578.408 726.543 579.083 728.747 581.753 L 731.082 582.41 L 731.176 582.867 L 726.53 584.639 L 724.612 585.739 C 724.581 587.228 724.594 588.346 724.344 589.834 C 719.434 590.443 720.625 586.8 719.083 588.443 L 717.026 587.357 C 713.327 580.991 713.974 566.97 712.227 557.729 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 685.653 591.101 C 685.338 587.886 685.055 586.581 685.92 583.372 C 688.878 580.708 688.069 581.511 692.928 581.291 C 694.444 584.065 700.06 593.42 699.544 596.052 L 698.71 597.083 L 694.432 600.95 C 691.789 598.275 688.178 593.989 685.653 591.101 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 694.265 579.448 C 697.769 580.081 703.619 581.332 706.975 581.357 C 705.383 584.794 703.633 587.799 703.589 591.562 C 705.08 595.247 704.017 594.035 707.712 596.011 C 705.433 596.183 701.934 596.066 699.544 596.052 C 700.06 593.42 694.444 584.065 692.928 581.291 L 694.265 579.448 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 676.257 560.312 C 679.779 551.698 690.108 556.447 695.813 558.979 L 699.85 559.444 C 703.954 561.429 704.462 562.513 706.968 566.425 C 708.601 571.034 708.439 575.407 707.283 580.153 L 706.975 581.357 C 703.619 581.332 697.769 580.081 694.265 579.448 C 688.48 574.333 682.157 566.235 676.257 560.312 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 695.813 558.979 L 699.85 559.444 C 703.954 561.429 704.462 562.513 706.968 566.425 C 708.601 571.034 708.439 575.407 707.283 580.153 C 705.226 569.297 698.488 573.239 695.813 558.979 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 705.042 538.969 C 704.987 536.646 705.851 520.903 706.955 519.447 L 709.837 520.372 C 710.981 522.436 713.508 527.409 715.03 528.769 C 716.639 528.774 717.734 528.399 719.277 528.004 L 721.095 531.637 L 716.542 536.968 C 715.162 537.637 715.392 537.351 713.511 536.917 C 712.968 540.462 713.657 539.236 710.955 541.528 L 706.49 538.03 L 705.042 538.969 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 719.277 528.004 L 721.095 531.637 L 716.542 536.968 C 715.162 537.637 715.392 537.351 713.511 536.917 L 712.378 533.385 C 712.995 530.124 712.402 531.357 715.03 528.769 C 716.639 528.774 717.734 528.399 719.277 528.004 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 706.49 538.03 C 708.014 533.56 708.123 533.004 712.378 533.385 L 713.511 536.917 C 712.968 540.462 713.657 539.236 710.955 541.528 L 706.49 538.03 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 713.511 536.917 C 715.392 537.351 715.162 537.637 716.542 536.968 L 720.036 541.456 C 712.229 550.822 721.542 552.64 718.498 563.715 C 716.495 561.262 718.039 553.171 713.697 550.91 C 711.382 556.551 714.451 552.517 712.227 557.729 C 708.333 558.084 709.256 557.453 706.52 559.65 C 706.077 562.855 706.161 563.281 706.968 566.425 C 704.462 562.513 703.954 561.429 699.85 559.444 C 699.589 557.504 699.537 558.242 701.077 556.185 C 702.365 556.181 703.941 556.093 705.153 555.595 C 708.454 554.255 711.12 551.707 712.605 548.468 C 712.55 544.717 712.857 546.04 710.126 542.781 L 710.955 541.528 C 713.657 539.236 712.968 540.462 713.511 536.917 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 706.49 538.03 L 710.955 541.528 L 710.126 542.781 L 706.239 547.178 L 705.246 546.41 L 704.737 540.071 L 705.042 538.969 L 706.49 538.03 z"/>
<path transform="translate(0,0)" fill="rgb(232,232,232)" d="M 372.224 554.989 C 400.138 554.484 415.609 593.782 379.538 609.834 C 353.962 612.557 341.45 584.287 356.34 565.169 C 360.446 559.896 366.301 557.65 372.224 554.989 z"/>
<path transform="translate(0,0)" fill="url(#Gradient2)" d="M 731.082 582.41 C 734.053 580.57 737.54 575.653 739.847 572.753 L 742.751 574.391 L 741.364 580.274 C 740.328 584.06 740.189 582.628 741.469 585.857 L 743.202 589.792 C 744.917 591.653 745.323 591.967 746.437 594.196 L 747.967 593.219 C 748.249 594.014 748.801 595.394 748.928 596.163 L 749.904 596.378 C 753.337 600.644 754.052 600.67 754.075 605.745 L 753.181 607.114 C 749.71 606.347 749.051 605.266 746.619 602.675 C 743.718 601.695 735.702 598.781 733.125 600.272 C 723.558 605.807 725.201 623.075 723.451 632.842 C 721.905 641.474 723.812 641.804 715.35 646.727 C 708.554 643.771 702.788 643.226 695.503 642.172 C 699.517 642.484 702.789 642.707 706.373 640.759 L 706.34 639.132 L 704.159 637.363 L 703.414 636.256 L 700.273 635.602 C 695.023 634.606 695.468 634.596 693.23 630.324 L 682.952 621.796 L 682.899 621.262 L 685.183 617.995 C 688.775 617.892 690.421 618.139 693.208 615.903 C 692.107 612.925 692.157 613.627 689.354 611.391 C 691.528 607.907 693.66 605 694.432 600.95 L 698.71 597.083 L 699.544 596.052 C 701.934 596.066 705.433 596.183 707.712 596.011 C 710.247 596.037 714.699 595.955 717.039 596.392 L 720.857 594.847 L 719.083 588.443 C 720.625 586.8 719.434 590.443 724.344 589.834 C 724.594 588.346 724.581 587.228 724.612 585.739 L 726.53 584.639 L 731.176 582.867 L 731.082 582.41 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 700.132 611.253 L 710.342 612.648 C 711.774 614.215 715.46 618.445 717.034 619.396 C 718.836 623.744 719.836 632.073 718.074 636.434 C 714.754 638.849 712.913 635.701 710.522 633.117 C 706.038 633.334 706.22 635.809 703.414 636.256 L 700.273 635.602 C 695.023 634.606 695.468 634.596 693.23 630.324 L 682.952 621.796 L 682.899 621.262 L 685.183 617.995 C 688.775 617.892 690.421 618.139 693.208 615.903 L 693.693 615.882 C 695.219 614.084 698.053 612.555 700.132 611.253 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 693.208 615.903 L 693.693 615.882 C 697.176 619.11 709.212 629.54 710.522 633.117 C 706.038 633.334 706.22 635.809 703.414 636.256 L 700.273 635.602 C 695.023 634.606 695.468 634.596 693.23 630.324 L 682.952 621.796 L 682.899 621.262 L 685.183 617.995 C 688.775 617.892 690.421 618.139 693.208 615.903 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 720.857 594.847 L 723.961 595.392 C 725.393 597.645 724.767 597.989 724.369 601.171 C 723.255 603.702 723.693 603.202 724.791 606.369 C 724.198 610.316 724.446 610.705 720.785 613.379 C 718.481 614.985 718.115 616.748 717.034 619.396 C 715.46 618.445 711.774 614.215 710.342 612.648 L 700.132 611.253 C 698.053 612.555 695.219 614.084 693.693 615.882 L 693.208 615.903 C 692.107 612.925 692.157 613.627 689.354 611.391 C 691.528 607.907 693.66 605 694.432 600.95 L 698.71 597.083 L 699.544 596.052 C 701.934 596.066 705.433 596.183 707.712 596.011 C 710.247 596.037 714.699 595.955 717.039 596.392 L 720.857 594.847 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 694.432 600.95 L 698.71 597.083 C 700.089 600.275 704.725 602.03 708.015 603.72 C 710.619 606.363 711 605.939 711.743 609.088 C 708.24 611.892 704.355 610.367 700.132 611.253 C 698.053 612.555 695.219 614.084 693.693 615.882 L 693.208 615.903 C 692.107 612.925 692.157 613.627 689.354 611.391 C 691.528 607.907 693.66 605 694.432 600.95 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 707.712 596.011 C 710.247 596.037 714.699 595.955 717.039 596.392 L 715.624 597.357 C 711.324 597.417 712.883 596.897 709.504 599.039 C 708.673 600.755 707.92 601.832 708.015 603.72 C 704.725 602.03 700.089 600.275 698.71 597.083 L 699.544 596.052 C 701.934 596.066 705.433 596.183 707.712 596.011 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 710.342 612.648 C 713.142 610.928 713.468 609.974 714.982 607.179 C 716.835 611.104 716.566 610.819 720.785 613.379 C 718.481 614.985 718.115 616.748 717.034 619.396 C 715.46 618.445 711.774 614.215 710.342 612.648 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 714.982 607.179 C 719.425 605.773 720.123 605.871 724.791 606.369 C 724.198 610.316 724.446 610.705 720.785 613.379 C 716.566 610.819 716.835 611.104 714.982 607.179 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 720.857 594.847 L 723.961 595.392 C 725.393 597.645 724.767 597.989 724.369 601.171 C 720.3 600.865 718.158 600.153 715.624 597.357 L 717.039 596.392 L 720.857 594.847 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 731.082 582.41 C 734.053 580.57 737.54 575.653 739.847 572.753 L 742.751 574.391 L 741.364 580.274 C 740.328 584.06 740.189 582.628 741.469 585.857 L 743.202 589.792 C 739.882 590.59 731.644 592.334 728.958 593.677 C 725.534 590.642 726.57 589.13 726.53 584.639 L 731.176 582.867 L 731.082 582.41 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 731.082 582.41 C 734.053 580.57 737.54 575.653 739.847 572.753 L 742.751 574.391 L 741.364 580.274 C 740.328 584.06 740.189 582.628 741.469 585.857 C 737.149 583.888 735.901 583.684 731.176 582.867 L 731.082 582.41 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 743.202 589.792 C 744.917 591.653 745.323 591.967 746.437 594.196 C 742.374 597.159 732.607 597.69 728.958 593.677 C 731.644 592.334 739.882 590.59 743.202 589.792 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 748.928 596.163 L 749.904 596.378 C 753.337 600.644 754.052 600.67 754.075 605.745 L 753.181 607.114 C 749.71 606.347 749.051 605.266 746.619 602.675 C 749.244 599.167 748.561 600.633 748.928 596.163 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" d="M 893.619 357.215 C 899.329 354.602 902.727 352.666 908.309 357.13 C 911.171 358.596 914.859 360.224 917.082 362.401 C 924.039 367.574 931.456 373.192 939.15 377.151 C 951.05 383.274 956.731 380.947 963.37 393.198 C 961.161 393.955 958.648 395.046 956.391 394.997 C 954.105 398.892 954.202 400.629 953.834 405.203 L 952.445 405.915 L 954.565 410.115 C 950.261 413.198 948.565 412.634 945.261 414.485 C 937.626 415.978 933.537 410.066 929.065 404.726 C 928.398 401.382 927.922 396.415 927.476 392.916 C 919.176 384.315 917.613 386.864 911.792 381.798 C 907.925 379.789 904.893 377.997 902.963 374.07 L 901.394 365.133 C 898.733 365.732 896.061 364.754 894.653 362.469 L 896.421 360.1 L 893.619 357.215 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 893.619 357.215 C 899.329 354.602 902.727 352.666 908.309 357.13 C 911.171 358.596 914.859 360.224 917.082 362.401 C 915.908 369.806 920.925 370.57 921.251 374.386 C 919.486 376.821 918.3 379.203 915.59 380.007 C 913.62 377.954 914.168 377.735 913.796 374.443 C 913.387 376.119 912.45 380.449 911.792 381.798 C 907.925 379.789 904.893 377.997 902.963 374.07 L 901.394 365.133 C 898.733 365.732 896.061 364.754 894.653 362.469 L 896.421 360.1 L 893.619 357.215 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 901.394 365.133 C 909.123 366.442 908.313 368.503 913.796 374.443 C 913.387 376.119 912.45 380.449 911.792 381.798 C 907.925 379.789 904.893 377.997 902.963 374.07 L 901.394 365.133 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 893.619 357.215 C 899.329 354.602 902.727 352.666 908.309 357.13 C 904.562 359.345 900.717 359.537 896.421 360.1 L 893.619 357.215 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 917.082 362.401 C 924.039 367.574 931.456 373.192 939.15 377.151 C 951.05 383.274 956.731 380.947 963.37 393.198 C 961.161 393.955 958.648 395.046 956.391 394.997 C 955.545 383.036 938.923 385.779 930.888 380.875 C 929.085 379.775 926.253 379.192 924.087 379.106 L 921.251 374.386 C 920.925 370.57 915.908 369.806 917.082 362.401 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 913.796 374.443 C 914.168 377.735 913.62 377.954 915.59 380.007 C 918.3 379.203 919.486 376.821 921.251 374.386 L 924.087 379.106 C 928.698 387.19 934.833 391.901 935.238 398.462 C 935.35 400.28 935.494 400.679 936.56 402.118 C 938.451 406.981 938.919 411.045 943.938 413.211 L 945.261 414.485 C 937.626 415.978 933.537 410.066 929.065 404.726 C 928.398 401.382 927.922 396.415 927.476 392.916 C 919.176 384.315 917.613 386.864 911.792 381.798 C 912.45 380.449 913.387 376.119 913.796 374.443 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 936.56 402.118 C 949.235 401.015 948.244 405.145 953.834 405.203 L 952.445 405.915 L 954.565 410.115 C 950.261 413.198 948.565 412.634 945.261 414.485 L 943.938 413.211 C 938.919 411.045 938.451 406.981 936.56 402.118 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 943.938 413.211 C 945.647 407.955 946.93 406.114 952.445 405.915 L 954.565 410.115 C 950.261 413.198 948.565 412.634 945.261 414.485 L 943.938 413.211 z"/>
<path transform="translate(0,0)" fill="url(#Gradient1)" d="M 893.619 357.215 L 896.421 360.1 L 894.653 362.469 C 896.061 364.754 898.733 365.732 901.394 365.133 L 902.963 374.07 C 896.975 372.58 895.509 373.388 890.102 376.749 C 881.086 382.354 870.582 390.162 863.175 397.704 C 862.847 400.847 862.542 404.983 862.068 408.016 C 861.995 401.132 862.345 398.802 859.036 392.549 C 856.356 387.597 851.827 383.906 846.437 382.28 L 847.902 381.876 L 846.16 378.431 L 848.262 374.416 C 848.839 373.158 851.193 371.723 852.482 370.768 C 855.79 372.195 857.243 374.277 858.953 374.839 C 864.973 371.875 890.207 360.173 893.619 357.215 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 852.482 370.768 C 855.79 372.195 857.243 374.277 858.953 374.839 C 854.318 377.36 852.118 378.717 847.902 381.876 L 846.16 378.431 L 848.262 374.416 C 848.839 373.158 851.193 371.723 852.482 370.768 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 971.787 475.044 C 981.972 472.515 982.282 471.469 992.596 473.503 C 995.359 476.104 1000.13 480.285 1002.17 483.237 L 1000.82 483.094 C 999.475 483.228 995.91 483.675 994.752 483.641 L 992.659 486.108 L 989.753 489.112 L 988.972 489.223 C 987.715 490.698 981.234 493.063 979.537 493.47 L 977.113 492.661 C 966.728 488.225 970.025 495.301 961.547 489.872 C 960.511 488.657 958.689 488.186 957.071 487.534 L 959.705 480.357 C 962.932 478.056 962.829 478.784 967.458 478.512 L 971.787 475.044 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 971.787 475.044 C 981.972 472.515 982.282 471.469 992.596 473.503 C 995.359 476.104 1000.13 480.285 1002.17 483.237 L 1000.82 483.094 C 999.475 483.228 995.91 483.675 994.752 483.641 L 992.659 486.108 L 989.753 489.112 L 988.972 489.223 C 986.064 488.553 979.797 483.307 977.046 481.225 L 976.825 477.086 L 971.787 475.044 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 976.825 477.086 C 980.206 478.03 992.49 481.766 994.752 483.641 L 992.659 486.108 L 989.753 489.112 L 988.972 489.223 C 986.064 488.553 979.797 483.307 977.046 481.225 L 976.825 477.086 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 971.787 475.044 L 976.825 477.086 L 977.046 481.225 L 973.442 483.812 C 969.871 483.717 967.29 483.232 964.522 485.409 L 961.547 489.872 C 960.511 488.657 958.689 488.186 957.071 487.534 L 959.705 480.357 C 962.932 478.056 962.829 478.784 967.458 478.512 L 971.787 475.044 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 971.787 475.044 L 976.825 477.086 L 977.046 481.225 L 973.442 483.812 C 970.845 483.391 969.451 483.45 967.522 481.707 L 967.458 478.512 L 971.787 475.044 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 681.677 592.131 L 683.183 589.101 L 685.653 591.101 C 688.178 593.989 691.789 598.275 694.432 600.95 C 693.66 605 691.528 607.907 689.354 611.391 C 692.157 613.627 692.107 612.925 693.208 615.903 C 690.421 618.139 688.775 617.892 685.183 617.995 L 682.899 621.262 C 678.046 620.606 677.367 618.891 675.205 614.908 C 674.002 613.013 672.134 611.695 670.384 610.232 C 667.296 606.723 667.296 605.265 666.322 600.651 C 667.457 599.096 668.448 598.709 670.099 597.752 L 670.052 597.345 C 673.785 595.121 672.624 596.005 674.892 592.036 C 677.828 590.015 678.302 590.144 681.677 592.131 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 681.677 592.131 L 683.183 589.101 L 685.653 591.101 C 688.178 593.989 691.789 598.275 694.432 600.95 C 693.66 605 691.528 607.907 689.354 611.391 C 692.157 613.627 692.107 612.925 693.208 615.903 C 690.421 618.139 688.775 617.892 685.183 617.995 C 683.669 616.453 682.992 615.032 681.931 613.183 C 682.735 610.496 682.943 606.78 683.23 603.913 C 683.042 600.123 682.273 595.919 681.677 592.131 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 683.23 603.913 L 685.332 603.388 C 687.477 605.741 688.229 608.408 689.354 611.391 C 692.157 613.627 692.107 612.925 693.208 615.903 C 690.421 618.139 688.775 617.892 685.183 617.995 C 683.669 616.453 682.992 615.032 681.931 613.183 C 682.735 610.496 682.943 606.78 683.23 603.913 z"/>
<path transform="translate(0,0)" fill="rgb(7,107,157)" d="M 674.892 592.036 C 677.828 590.015 678.302 590.144 681.677 592.131 C 682.273 595.919 683.042 600.123 683.23 603.913 C 682.943 606.78 682.735 610.496 681.931 613.183 C 682.174 607.424 683.418 604.918 679.114 601.096 C 672.623 600.29 675.102 599.787 670.099 597.752 L 670.052 597.345 C 673.785 595.121 672.624 596.005 674.892 592.036 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 990.573 442.362 C 994.703 442.53 993.658 441.903 996.567 444.146 C 1001.55 454.946 992.717 465.829 980.178 464.88 C 978.002 463.476 977.421 462.795 975.574 460.94 C 972.134 462.013 968.975 462.838 965.834 464.619 L 962.641 461.705 L 965.44 455.944 C 968.253 451.558 968.53 450.111 969.888 445.01 C 973.83 445.426 972.319 444.812 975.538 447.647 C 977.22 447.392 979.466 446.649 981.159 446.159 C 983.795 442.601 985.829 443.201 990.573 442.362 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 969.888 445.01 C 973.83 445.426 972.319 444.812 975.538 447.647 L 976.429 449.955 C 973.284 452.883 973.445 451.833 972.895 455.382 L 975.574 460.94 C 972.134 462.013 968.975 462.838 965.834 464.619 L 962.641 461.705 L 965.44 455.944 C 968.253 451.558 968.53 450.111 969.888 445.01 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 976.429 449.955 C 978.68 452.429 983.037 457.51 985.482 459.373 L 986.539 462.125 C 984.271 464.489 983.916 463.939 980.178 464.88 C 978.002 463.476 977.421 462.795 975.574 460.94 L 972.895 455.382 C 973.445 451.833 973.284 452.883 976.429 449.955 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 981.159 446.159 C 983.989 448.415 988.493 451.378 989.552 454.584 C 988.817 457.453 988.629 457.389 985.482 459.373 C 983.037 457.51 978.68 452.429 976.429 449.955 L 975.538 447.647 C 977.22 447.392 979.466 446.649 981.159 446.159 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 666.322 600.651 C 667.296 605.265 667.296 606.723 670.384 610.232 C 672.134 611.695 674.002 613.013 675.205 614.908 C 672.089 618.171 672.957 617.819 671.872 622.913 L 668.647 624.285 L 668.907 624.891 C 667.653 626.645 665.717 629.172 664.778 631.013 L 663.369 630.541 L 663.121 634.027 L 663.305 636.746 C 661.468 638.312 659.618 638.08 657.096 638.242 L 656.285 637.039 C 654.096 632.762 653.194 628.597 651.979 623.978 C 651.697 619.242 651.786 617.028 654.265 613.118 C 652.701 609.396 652.985 606.288 652.963 602.231 C 655.31 603.903 657.866 605.825 660.306 607.311 C 661.897 605.728 663.84 603.398 665.376 601.67 L 666.322 600.651 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 654.265 613.118 C 655.587 616.436 657.323 621.567 659.495 624.187 C 663.373 627.701 663.68 631.859 659.975 635.651 L 656.285 637.039 C 654.096 632.762 653.194 628.597 651.979 623.978 C 651.697 619.242 651.786 617.028 654.265 613.118 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 652.963 602.231 C 655.31 603.903 657.866 605.825 660.306 607.311 L 659.871 609.452 L 660.973 610.871 C 663.19 613.718 663.092 612.962 663.672 616.981 C 662.673 620.195 662.04 621.723 659.495 624.187 C 657.323 621.567 655.587 616.436 654.265 613.118 C 652.701 609.396 652.985 606.288 652.963 602.231 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 670.384 610.232 C 672.134 611.695 674.002 613.013 675.205 614.908 C 672.089 618.171 672.957 617.819 671.872 622.913 L 668.647 624.285 L 667.544 622.994 C 666.877 618.726 668.524 614.021 670.384 610.232 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 1002.45 548.257 C 1005.03 549.174 1005.25 549.662 1007.23 551.701 C 1005.22 553.79 1003.25 555.921 1001.33 558.095 L 1001.56 559.783 C 1006.61 561.225 1010.3 560.133 1015.03 560.973 C 1015.85 564.098 1016 562.819 1014.43 565.756 C 1008.48 570.224 1001.15 571.128 993.9 571.351 C 990.529 572.083 989.719 574.023 986.89 574.156 C 985.262 569.58 987.333 568.683 984.519 565.658 C 981.518 566.867 980.769 567.112 977.614 567.771 L 976.826 566.583 C 978.392 560.814 982.772 560.557 987.611 558.01 C 990.324 554.304 995.087 557.304 1002.45 548.257 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 1015.03 560.973 C 1015.85 564.098 1016 562.819 1014.43 565.756 C 1008.48 570.224 1001.15 571.128 993.9 571.351 C 992.678 570.927 991.711 570.276 990.579 569.627 L 990.063 567.684 C 994.281 560.949 1008.02 561.045 1015.03 560.973 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 940.434 439.005 C 941.108 438.509 944.682 433.661 945.424 432.708 C 948.1 433.485 947.652 433.91 949.468 436.772 C 953.298 434.975 953.915 433.876 957.416 434.372 C 959.796 438.083 960.613 446.823 965.44 455.944 L 962.641 461.705 L 965.834 464.619 C 952.227 473.327 955.216 452.699 950.543 448.57 L 948.332 452.341 C 947.554 446.231 944.94 443.006 940.434 439.005 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 940.434 439.005 C 941.108 438.509 944.682 433.661 945.424 432.708 C 948.1 433.485 947.652 433.91 949.468 436.772 C 948.504 439.311 947.92 440.318 949.687 443.302 C 952.647 448.301 955.209 456.862 959.895 459.962 L 960.685 460.495 C 961.367 460.949 961.939 461.297 962.641 461.705 L 965.834 464.619 C 952.227 473.327 955.216 452.699 950.543 448.57 L 948.332 452.341 C 947.554 446.231 944.94 443.006 940.434 439.005 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 649.781 541.041 C 649.559 536.855 648.972 537.935 651.216 535.337 C 656.367 540.109 663.214 545.781 666.58 551.831 C 667.781 554.419 668.342 556.122 670.361 558.162 C 666.177 557.139 660.961 554.48 657.898 555.6 C 657.287 557.142 657.134 557.312 656.974 559.013 L 658.106 559.972 L 655.687 563.927 L 653.932 560.619 C 653.414 557.445 652.512 556.081 651.038 553.304 C 648.115 551.617 646.386 549.274 645.74 546.01 C 646.723 544.6 648.625 542.443 649.781 541.041 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 649.781 541.041 C 659.428 552.461 653.62 550.299 656.974 559.013 L 658.106 559.972 L 655.687 563.927 L 653.932 560.619 C 653.414 557.445 652.512 556.081 651.038 553.304 C 648.115 551.617 646.386 549.274 645.74 546.01 C 646.723 544.6 648.625 542.443 649.781 541.041 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 649.46 624.832 L 651.979 623.978 C 653.194 628.597 654.096 632.762 656.285 637.039 L 657.096 638.242 C 656.437 642.651 656.465 641.122 658.14 645.469 L 651.914 647.5 C 648.219 642.947 639.424 639.541 634.331 632.738 C 639.519 632.643 640.353 631.977 643.629 628.878 C 646.079 627.913 647.44 626.521 649.46 624.832 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 671.872 622.913 C 672.957 617.819 672.089 618.171 675.205 614.908 C 677.367 618.891 678.046 620.606 682.899 621.262 L 682.952 621.796 L 693.23 630.324 C 689.584 633.121 684.097 635.88 679.422 635.443 C 678.504 633.037 678.37 632.742 676.273 631.269 C 673.991 628.604 672.203 625.901 668.907 624.891 L 668.647 624.285 L 671.872 622.913 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 671.872 622.913 C 672.957 617.819 672.089 618.171 675.205 614.908 C 677.367 618.891 678.046 620.606 682.899 621.262 L 682.952 621.796 C 680.522 622.121 674.084 623.035 671.872 622.913 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 693.23 630.324 C 695.468 634.596 695.023 634.606 700.273 635.602 L 703.414 636.256 L 704.159 637.363 L 706.34 639.132 L 706.373 640.759 C 702.789 642.707 699.517 642.484 695.503 642.172 L 686.733 641.764 C 683.026 639.321 682.356 642.419 679.048 639.646 L 679.422 635.443 C 684.097 635.88 689.584 633.121 693.23 630.324 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 700.273 635.602 L 703.414 636.256 L 704.159 637.363 L 703.716 639.665 C 701.556 641.525 702.559 640.955 699.433 641.561 C 696.303 640.188 697.094 641.051 695.867 638.526 L 696.754 636.713 L 700.273 635.602 z"/>
<path transform="translate(0,0)" fill="rgb(2,75,117)" d="M 713.675 501.485 L 714.434 507.282 C 716.495 508.46 715.403 508.274 717.839 507.687 C 718.872 509.171 719.777 510.72 721.093 511.902 C 721.239 518.216 722.319 517.95 722.765 521.566 L 719.277 528.004 C 717.734 528.399 716.639 528.774 715.03 528.769 C 713.508 527.409 710.981 522.436 709.837 520.372 L 713.556 519.775 C 717.146 515.754 712.75 510.843 713.675 501.485 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 631.263 615.231 C 634.019 618.036 632.951 617.581 637.865 617.612 C 638.541 617.613 639.217 617.61 639.893 617.602 C 639.656 623.156 640.755 623.922 643.629 628.878 C 640.353 631.977 639.519 632.643 634.331 632.738 C 633.606 628.207 630.772 620.641 626.912 617.884 C 629.394 617.348 629.449 617.155 631.263 615.231 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 604.919 573.787 C 605.823 576.859 605.992 577.198 608.431 579.259 C 608.208 583.547 609.757 584.452 612.969 588.032 C 610.474 591.986 607.678 591.771 605.928 594.316 C 600.094 589.678 601.918 581.603 601.72 574.829 C 602.968 574.96 603.768 574.368 604.919 573.787 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 741.364 580.274 C 745.23 581.555 747.059 580.888 751.11 579.26 C 753.458 581.033 756.096 582.871 758.136 584.951 C 754.92 584.87 751.696 584.363 749.195 586.279 C 747.969 589.104 749.247 593.226 749.904 596.378 L 748.928 596.163 C 748.801 595.394 748.249 594.014 747.967 593.219 L 746.437 594.196 C 745.323 591.967 744.917 591.653 743.202 589.792 L 741.469 585.857 C 740.189 582.628 740.328 584.06 741.364 580.274 z"/>
<path transform="translate(0,0)" fill="rgb(1,43,72)" d="M 754.125 574.417 C 759.444 577.066 765 579.357 768.905 583.769 L 768.884 585.23 C 766.069 586.659 761.146 585.737 758.136 584.951 C 756.096 582.871 753.458 581.033 751.11 579.26 L 751.184 576.808 L 754.125 574.417 z"/>
<path transform="translate(0,0)" fill="rgb(1,69,111)" d="M 863.175 397.704 C 864.693 402.544 869.429 403.684 873.072 409.09 C 871.636 411.647 871.177 411.843 868.618 413.333 C 867.584 415.37 867.426 415.979 866.8 418.193 C 863.924 416.994 863.212 416.772 860.81 414.655 L 862.625 414.64 L 863.613 413.567 L 862.068 408.016 C 862.542 404.983 862.847 400.847 863.175 397.704 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 963.37 393.198 C 963.078 398.729 958.264 406.339 954.565 410.115 L 952.445 405.915 L 953.834 405.203 C 954.202 400.629 954.105 398.892 956.391 394.997 C 958.648 395.046 961.161 393.955 963.37 393.198 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 969.888 445.01 C 980.695 433.355 977.771 442.341 990.146 442.364 L 990.573 442.362 C 985.829 443.201 983.795 442.601 981.159 446.159 C 979.466 446.649 977.22 447.392 975.538 447.647 C 972.319 444.812 973.83 445.426 969.888 445.01 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 807.838 465.305 L 811.978 468.013 C 810.35 472.824 810.873 470.732 806.15 474.381 L 793.652 472.48 C 797.016 471.554 796.487 472.267 798.11 470.02 C 802.353 469.371 804.247 467.725 807.838 465.305 z"/>
<path transform="translate(0,0)" fill="rgb(1,26,44)" d="M 781.6 284.515 C 784.877 280.894 783.931 280.938 788.06 279.498 C 791.545 281.275 792.203 283.15 793.718 287.066 C 791.306 288.463 790.689 288.939 787.962 289.398 L 785.798 289.684 L 781.6 284.515 z"/>
<path transform="translate(0,0)" fill="rgb(5,89,134)" d="M 600.019 543.195 C 601.185 539.294 602.47 533.587 604.95 530.593 C 606.35 531.752 607.79 532.612 608.321 534.267 C 607.476 536.805 606.487 539 606.647 541.626 C 604.774 541.992 601.665 542.474 600.019 543.195 z"/>
<path transform="translate(0,0)" fill="rgb(1,55,93)" d="M 837.119 342.81 C 841.586 344.835 843.37 345.373 844.579 350.501 L 839.119 353.295 C 836.816 351.666 835.792 350.055 834.071 347.843 L 837.119 342.81 z"/>
<path transform="translate(0,0)" fill="rgb(4,88,136)" d="M 715.195 343.605 C 714.026 338.169 714.491 337.547 717.25 333.039 C 718.773 335.214 719.089 335.344 721.599 336.254 L 720.915 342.17 L 715.195 343.605 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" fill-opacity="0.988235" d="M 796.531 425.808 L 802.12 425.571 C 797.522 426.978 796.824 426.454 794.618 431.249 C 792.147 432.934 788.952 432.292 785.738 432.14 C 786.544 429.441 786.868 429.076 788.594 426.829 C 792.349 426.926 792.927 426.934 796.531 425.808 z"/>
<path transform="translate(0,0)" fill="rgb(0,120,185)" fill-opacity="0.984314" d="M 742.751 574.391 C 745.829 574.876 748.649 575.033 751.184 576.808 L 751.11 579.26 C 747.059 580.888 745.23 581.555 741.364 580.274 L 742.751 574.391 z"/>
<path transform="translate(0,0)" fill="rgb(1,63,102)" d="M 739.847 572.753 C 745.987 569.781 748.117 571.67 754.125 574.417 L 751.184 576.808 C 748.649 575.033 745.829 574.876 742.751 574.391 L 739.847 572.753 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 127 KiB

-102
View File
@@ -21,84 +21,6 @@ bun test main.test.ts # Run single TS test (from
- **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md`
- **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute
## Module Data Layout
All runtime data a module writes on the workspace MUST live under a single per-module root:
```
$HOME/.coder-modules/<namespace>/<module-name>/
```
For a Coder-owned module named `claude-code`, the root is `$HOME/.coder-modules/coder/claude-code/`.
Within that root, use these standard subdirectories:
| Subdirectory | Purpose | Example |
| ------------ | ----------------------------------------- | ----------------------------------------------------------- |
| `logs/` | Output from install, start, or any script | `$HOME/.coder-modules/coder/claude-code/logs/install.log` |
| `scripts/` | Scripts materialized at runtime (if any) | `$HOME/.coder-modules/coder/claude-code/scripts/install.sh` |
- Name log files after the script that produced them (`install.sh` writes to `logs/install.log`, `start.sh` writes to `logs/start.log`).
- Always `mkdir -p` the target directory before writing; do not assume it exists.
- Do not write module runtime data to `$HOME` directly, to ad-hoc paths like `~/.<module>-module/`, or to `/tmp/` for anything that must survive the session.
- Tool-specific data (config files, caches, state, etc.) lives wherever the tool expects; only standardize paths the module itself controls.
- READMEs and tests should reference paths under this root so troubleshooting has one place to look.
- New modules MUST follow this layout. Existing modules should migrate to it when they are next touched.
## Use `coder-utils` for Script Orchestration
For any new module that runs scripts (or when reworking an existing one), use the [`coder-utils`](registry/coder/modules/coder-utils) module to orchestrate `pre_install`, `install`, `post_install`, and `start` scripts instead of hand-rolling `coder_script` resources.
- `coder-utils` handles script ordering via `coder exp sync`, materializes scripts under `module_directory/scripts/` (e.g., `install.sh`, `start.sh`), and writes logs to `module_directory/logs/` automatically, which aligns with the Module Data Layout above.
- Set `module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"` so the standard root, `scripts/`, and `logs/` subdirectories fall out for free.
### Passing scripts to `coder-utils`
Store each script as a `.tftpl` file under `scripts/`. Render it at **plan time** in a `locals` block using `templatefile()`, then pass the rendered string directly to the `coder-utils` module.
**Encoding rules for template variables:**
| Value type | Terraform side | Template (`.tftpl`) side |
| ------------------------------------- | ----------------------------------- | ---------------------------------------------- |
| String / path | pass as-is | `ARG_FOO='${ARG_FOO}'` |
| Boolean | `tostring(var.foo)` | `ARG_FOO='${ARG_FOO}'` |
| Free-form string (may contain quotes) | `base64encode(var.foo)` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
| Object / list (JSON) | `base64encode(jsonencode(var.foo))` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
In `.tftpl` files, write literal bash `$` as `$$` (e.g., `$${HOME}`) so Terraform does not treat them as template interpolations.
```tf
locals {
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_FOO = var.foo
ARG_BAR = var.bar
})
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"
display_name_prefix = "My Module"
icon = var.icon
pre_install_script = var.pre_install_script
install_script = local.install_script
post_install_script = var.post_install_script
start_script = var.start_script # optional; omit if the module does not start a process
}
```
Always expose the `scripts` output as a pass-through so upstream modules can serialize their own `coder_script` resources behind this module's install pipeline:
```tf
output "scripts" {
description = "Ordered list of coder exp sync names produced by this module, in run order."
value = module.coder_utils.scripts
}
```
## Code Style
- Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests
@@ -106,30 +28,6 @@ output "scripts" {
- Use semantic versioning; bump version via script when modifying modules
- Docker tests require Linux or Colima/OrbStack (not Docker Desktop)
- Use `tf` (not `hcl`) for code blocks in README; use relative icon paths (e.g., `../../../../.icons/`)
- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift.
- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
### Variable and output conventions
Order variable blocks: `description``type``default``validation``sensitive`.
```tf
variable "api_key" {
description = "API key for the service."
type = string
default = ""
sensitive = true
}
```
- Mark variables and outputs that hold secrets or tokens `sensitive = true`.
- Every `output` block must have a `description`.
- Use `count = condition ? 1 : 0` for optional singleton resources. Reserve `for_each` for maps/sets where resource identity matters.
### `.tftest.hcl` test commands
- Use `command = plan` only for assertions on **input-derived values** (variables, locals computed from inputs).
- Use `command = apply` for **computed attributes** (resource IDs, anything the provider generates), and for nested blocks of set type (they cannot be indexed with `[0]` under `plan`).
## PR Review Checklist
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

-11
View File
@@ -1,11 +0,0 @@
---
display_name: Ben Potter
bio: Tinkerer and Product Manager at Coder
github: bpmct
avatar: ./.images/avatar.png
status: community
---
# Ben Potter
Tinkerer and Product Manager at Coder. Building modules to make dev environments better.
@@ -1,97 +0,0 @@
---
display_name: "1Password"
description: "Install the 1Password CLI and VS Code extension in your Coder workspace"
icon: ../../../../.icons/1password.svg
verified: false
tags: [integration, 1password, secrets]
---
# 1Password
Install the [1Password CLI](https://developer.1password.com/docs/cli/)
(`op`) in your Coder workspace and optionally authenticate with a service
account token. Can also install the
[1Password VS Code extension](https://marketplace.visualstudio.com/items?itemName=1Password.op-vscode)
for code-server and VS Code.
![1Password module in Coder](../../.images/onepassword-demo.png)
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
}
```
## Authentication
### Service Account (recommended)
Create a [1Password service account](https://developer.1password.com/docs/service-accounts/get-started/)
and pass the token as a Terraform variable. The module sets
`OP_SERVICE_ACCOUNT_TOKEN` in the workspace so `op` commands work
immediately.
```tf
variable "op_service_account_token" {
type = string
sensitive = true
}
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
}
```
### Personal Account
Pass your account details and the module will pre-register the account.
You'll be prompted for your password when you run `op signin` in the
terminal.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
account_address = "myteam.1password.com"
account_email = "you@example.com"
account_secret_key = var.op_secret_key
}
```
## VS Code Extension
Set `install_vscode_extension = true` to install the 1Password extension
for code-server and VS Code.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
install_vscode_extension = true
}
```
## Custom Scripts
Run custom logic before or after the CLI is installed.
```tf
module "onepassword" {
source = "registry.coder.com/bpmct/onepassword/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
service_account_token = var.op_service_account_token
post_install_script = <<-EOT
op read "op://Vault/item/field" > ~/.secret
EOT
}
```
-112
View File
@@ -1,112 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "service_account_token" {
type = string
description = "A 1Password service account token. If set, account-based sign-in is skipped."
default = ""
sensitive = true
}
variable "account_address" {
type = string
description = "The 1Password account sign-in address (e.g. myteam.1password.com)."
default = ""
}
variable "account_email" {
type = string
description = "The email address for the 1Password account."
default = ""
}
variable "account_secret_key" {
type = string
description = "The Secret Key for the 1Password account."
default = ""
sensitive = true
}
variable "install_dir" {
type = string
description = "The directory to install the 1Password CLI to."
default = "/usr/local/bin"
}
variable "op_cli_version" {
type = string
description = "The version of the 1Password CLI to install."
default = "latest"
validation {
condition = var.op_cli_version == "latest" || can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.op_cli_version))
error_message = "op_cli_version must be either 'latest' or a semantic version (e.g., '2.30.0')."
}
}
variable "install_vscode_extension" {
type = bool
description = "Install the 1Password VS Code extension for both VS Code and code-server."
default = false
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the 1Password CLI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the 1Password CLI."
default = null
}
data "coder_parameter" "account_password" {
count = var.account_address != "" && var.service_account_token == "" ? 1 : 0
type = "string"
name = "op_account_password"
display_name = "1Password Account Password"
description = "Your 1Password account password. Used to sign in to the CLI."
mutable = true
default = ""
}
resource "coder_script" "onepassword" {
agent_id = var.agent_id
display_name = "1Password CLI"
icon = "/icon/1password.svg"
script = templatefile("${path.module}/run.sh", {
SERVICE_ACCOUNT_TOKEN = var.service_account_token
ACCOUNT_ADDRESS = var.account_address
ACCOUNT_EMAIL = var.account_email
ACCOUNT_SECRET_KEY = var.account_secret_key
ACCOUNT_PASSWORD = var.account_address != "" && var.service_account_token == "" ? data.coder_parameter.account_password[0].value : ""
INSTALL_DIR = var.install_dir
OP_CLI_VERSION = var.op_cli_version
INSTALL_VSCODE_EXTENSION = var.install_vscode_extension
PRE_INSTALL_SCRIPT = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
POST_INSTALL_SCRIPT = var.post_install_script != null ? base64encode(var.post_install_script) : ""
})
run_on_start = true
start_blocks_login = true
}
resource "coder_env" "op_service_account_token" {
count = var.service_account_token != "" ? 1 : 0
agent_id = var.agent_id
name = "OP_SERVICE_ACCOUNT_TOKEN"
value = var.service_account_token
}
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env bash
SERVICE_ACCOUNT_TOKEN="${SERVICE_ACCOUNT_TOKEN}"
ACCOUNT_ADDRESS="${ACCOUNT_ADDRESS}"
ACCOUNT_EMAIL="${ACCOUNT_EMAIL}"
ACCOUNT_SECRET_KEY="${ACCOUNT_SECRET_KEY}"
ACCOUNT_PASSWORD="${ACCOUNT_PASSWORD}"
INSTALL_DIR="${INSTALL_DIR}"
OP_CLI_VERSION="${OP_CLI_VERSION}"
INSTALL_VSCODE_EXTENSION="${INSTALL_VSCODE_EXTENSION}"
PRE_INSTALL_SCRIPT="${PRE_INSTALL_SCRIPT}"
POST_INSTALL_SCRIPT="${POST_INSTALL_SCRIPT}"
fetch() {
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$1"
elif command -v wget > /dev/null 2>&1; then
wget -qO- "$1"
else
printf "curl or wget is not installed.\n" && return 1
fi
}
fetch_to_file() {
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$2" -o "$1"
elif command -v wget > /dev/null 2>&1; then
wget -O "$1" "$2"
else
printf "curl or wget is not installed.\n" && return 1
fi
}
run_script() {
local ENCODED="$1" LABEL="$2"
if [ -n "$${ENCODED}" ]; then
printf "Running %s script...\n" "$${LABEL}"
SCRIPT_PATH=$(mktemp /tmp/op-"$${LABEL}"-XXXXXX.sh)
printf '%s' "$${ENCODED}" | base64 -d > "$${SCRIPT_PATH}"
chmod +x "$${SCRIPT_PATH}"
# shellcheck disable=SC2288
"$${SCRIPT_PATH}" || printf "WARNING: %s script failed.\n" "$${LABEL}"
rm -f "$${SCRIPT_PATH}"
fi
}
install() {
ARCH=$(uname -m)
if [ "$${ARCH}" = "x86_64" ]; then
ARCH="amd64"
elif [ "$${ARCH}" = "aarch64" ]; then
ARCH="arm64"
else
printf "Unsupported architecture: %s\n" "$${ARCH}" && return 1
fi
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
if [ "$${OS}" != "linux" ] && [ "$${OS}" != "darwin" ]; then
printf "Unsupported OS: %s\n" "$${OS}" && return 1
fi
if [ "$${OP_CLI_VERSION}" = "latest" ]; then
OP_CLI_VERSION=$(fetch "https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N" \
| grep -oE '"version":"[^"]+"' | head -1 | cut -d'"' -f4) || true
if [ -z "$${OP_CLI_VERSION}" ]; then
printf "Failed to resolve latest version, falling back to 2.30.3.\n"
OP_CLI_VERSION="2.30.3"
fi
fi
printf "1Password CLI version: %s\n" "$${OP_CLI_VERSION}"
if command -v op > /dev/null 2>&1; then
CURRENT_VERSION=$(op --version 2> /dev/null || true)
if [ "$${CURRENT_VERSION}" = "$${OP_CLI_VERSION}" ]; then
printf "Already installed.\n"
return 0
fi
fi
DOWNLOAD_URL="https://cache.agilebits.com/dist/1P/op2/pkg/v$${OP_CLI_VERSION}/op_$${OS}_$${ARCH}_v$${OP_CLI_VERSION}.zip"
TEMP_DIR=$(mktemp -d)
cd "$${TEMP_DIR}" || return 1
if ! fetch_to_file op.zip "$${DOWNLOAD_URL}"; then
rm -rf "$${TEMP_DIR}" && return 1
fi
if command -v unzip > /dev/null 2>&1; then
unzip -o op.zip -d . > /dev/null
elif command -v busybox > /dev/null 2>&1; then
busybox unzip op.zip -d .
else
printf "unzip is not installed.\n"
rm -rf "$${TEMP_DIR}" && return 1
fi
chmod +x op
if [ -n "$${INSTALL_DIR}" ] && [ -w "$${INSTALL_DIR}" ]; then
mv op "$${INSTALL_DIR}/op"
elif [ -n "$${INSTALL_DIR}" ] && sudo mv op "$${INSTALL_DIR}/op" 2> /dev/null; then
true
else
mkdir -p ~/.local/bin && mv op ~/.local/bin/op
INSTALL_DIR=~/.local/bin
fi
printf "Installed to %s.\n" "$${INSTALL_DIR}"
rm -rf "$${TEMP_DIR}"
}
run_script "$${PRE_INSTALL_SCRIPT}" "pre-install"
if ! install; then
printf "Failed to install 1Password CLI.\n"
exit 1
fi
if [ -n "$${SERVICE_ACCOUNT_TOKEN}" ]; then
printf "Service account token configured.\n"
elif [ -n "$${ACCOUNT_ADDRESS}" ] && [ -n "$${ACCOUNT_EMAIL}" ]; then
ADD_ARGS="--address $${ACCOUNT_ADDRESS} --email $${ACCOUNT_EMAIL}"
if [ -n "$${ACCOUNT_SECRET_KEY}" ]; then
ADD_ARGS="$${ADD_ARGS} --secret-key $${ACCOUNT_SECRET_KEY}"
fi
if [ -n "$${ACCOUNT_PASSWORD}" ] && command -v expect > /dev/null 2>&1; then
OP_SESSION=$(expect -c "
log_user 0
spawn op account add $${ADD_ARGS} --raw
expect \"Enter the password*\"
send \"$${ACCOUNT_PASSWORD}\r\"
expect eof
catch wait result
set output \$expect_out(buffer)
puts -nonewline \$output
" 2>&1)
if op account list 2> /dev/null | grep -q "$${ACCOUNT_ADDRESS}"; then
printf "Signed in to %s.\n" "$${ACCOUNT_ADDRESS}"
if [ -n "$${OP_SESSION}" ]; then
mkdir -p "$${HOME}/.op"
SESSION_VAR="OP_SESSION_$(printf '%s' "$${ACCOUNT_ADDRESS}" | tr '.' '_' | tr '-' '_')"
printf 'export %s="%s"\n' "$${SESSION_VAR}" "$${OP_SESSION}" > "$${HOME}/.op/session"
chmod 600 "$${HOME}/.op/session"
for rc in "$${HOME}/.bashrc" "$${HOME}/.zshrc"; do
if [ -f "$${rc}" ] && ! grep -q ".op/session" "$${rc}" 2> /dev/null; then
printf '\n[ -f ~/.op/session ] && . ~/.op/session\n' >> "$${rc}"
fi
done
fi
else
printf "Sign-in failed. Run manually: op signin --account %s\n" "$${ACCOUNT_ADDRESS}"
fi
else
printf "To sign in, run in your terminal:\n"
printf " op account add %s\n" "$${ADD_ARGS}"
fi
fi
if [ "$${INSTALL_VSCODE_EXTENSION}" = "true" ]; then
EXTENSION_ID="1Password.op-vscode"
for _ in 1 2 3 4 5 6; do
command -v code-server > /dev/null 2>&1 || command -v code > /dev/null 2>&1 && break
sleep 5
done
if command -v code-server > /dev/null 2>&1; then
cd /tmp && code-server --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
fi
if command -v code > /dev/null 2>&1; then
cd /tmp && code --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
fi
fi
run_script "$${POST_INSTALL_SCRIPT}" "post-install"
+120 -94
View File
@@ -1,107 +1,134 @@
---
display_name: Codex CLI
icon: ../../../../.icons/openai.svg
description: Install and configure the Codex CLI in your workspace.
description: Run Codex CLI in your workspace with AgentAPI integration
verified: true
tags: [agent, codex, ai, openai, ai-gateway]
tags: [agent, codex, ai, openai, tasks, aibridge]
---
# Codex CLI
Install and configure the [Codex CLI](https://github.com/openai/codex) in your workspace.
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
agent_id = coder_agent.main.id
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
}
```
> [!WARNING]
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
## Prerequisites
- OpenAI API key for Codex access
## Examples
### Standalone mode with a launcher app
### Run standalone
```tf
locals {
codex_workdir = "/home/coder/project"
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
report_tasks = false
}
```
### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version 2.30+
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
}
```
When `enable_aibridge = true`, the module:
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex then runs with `--profile aibridge`
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
### Usage with Tasks
This example shows how to configure Codex with Coder tasks.
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.codex.task_app_id
}
data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
agent_id = coder_agent.main.id
workdir = local.codex_workdir
openai_api_key = var.openai_api_key
}
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
workdir = "/home/coder/project"
resource "coder_app" "codex" {
agent_id = coder_agent.main.id
slug = "codex"
display_name = "Codex"
icon = "/icon/openai.svg"
open_in = "slim-window"
command = <<-EOT
#!/bin/bash
set -e
cd "${local.codex_workdir}"
codex
EOT
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
> [!NOTE]
> The `coder_app` command re-executes on every pane reconnect. This works for interactive `codex` (which stays alive), but one-shot commands like `codex exec` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (e.g. via tmux/screen).
### Usage with AI Gateway
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
}
```
When `enable_ai_gateway = true`, the module configures Codex to use the `aigateway` model provider in `config.toml` with the workspace owner's session token for authentication.
> [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `openai_api_key`. Setting both fails at plan time.
> [!NOTE]
> If you provide a custom `base_config_toml`, the module writes it verbatim and does not inject `model_provider = "aigateway"` automatically. Add it to your config yourself:
>
> ```toml
> model_provider = "aigateway"
> ```
### Advanced Configuration
This example shows additional configuration options for custom models, MCP servers, and base configuration.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
agent_id = coder_agent.main.id
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
openai_api_key = var.openai_api_key
codex_version = "0.128.0"
codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
# Override default configuration
base_config_toml = <<-EOT
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
EOT
mcp = <<-EOT
# Add extra MCP servers
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
@@ -110,49 +137,48 @@ module "codex" {
}
```
### Serialize a downstream `coder_script` after the install pipeline
> [!WARNING]
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent.
## How it Works
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
}
resource "coder_script" "post_codex" {
agent_id = coder_agent.main.id
display_name = "Run after Codex install"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -euo pipefail
trap 'coder exp sync complete post-codex' EXIT
coder exp sync want post-codex ${join(" ", module.codex.scripts)}
coder exp sync start post-codex
codex --version
EOT
}
```
- **Install**: The module installs Codex CLI and sets up the environment
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
## Configuration
When no custom `base_config_toml` is provided, the module uses a minimal default with `preferred_auth_method = "apikey"`. For advanced options, see [Codex config docs](https://developers.openai.com/codex/config-advanced).
### Default Configuration
When no custom `base_config_toml` is provided, the module uses these secure defaults:
```toml
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
[sandbox_workspace_write]
network_access = true
```
> [!NOTE]
> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md).
## Troubleshooting
Check the log files in `~/.coder-modules/coder-labs/codex/logs/` for detailed information.
- Check installation and startup logs in `~/.codex-module/`
- Ensure your OpenAI API key has access to the specified model
```bash
cat ~/.coder-modules/coder-labs/codex/logs/install.log
cat ~/.coder-modules/coder-labs/codex/logs/pre_install.log
cat ~/.coder-modules/coder-labs/codex/logs/post_install.log
```
> [!IMPORTANT]
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
> The module automatically configures Codex with your API key and model preferences.
> workdir is a required variable for the module to function correctly.
## References
- [Codex CLI Documentation](https://github.com/openai/codex)
- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
- [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge)
+369 -331
View File
@@ -6,67 +6,15 @@ import {
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
execContainer,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
TerraformState,
} from "~test";
import {
extractCoderEnvVars,
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../../../coder/modules/agentapi/test-util";
import path from "path";
interface ModuleScripts {
pre_install?: string;
install: string;
post_install?: string;
}
const SCRIPT_SUFFIXES = [
"Pre-Install Script",
"Install Script",
"Post-Install Script",
] as const;
const collectScripts = (state: TerraformState): ModuleScripts => {
const byDisplayName: Record<string, string> = {};
for (const resource of state.resources) {
if (resource.type !== "coder_script") continue;
for (const instance of resource.instances) {
const attrs = instance.attributes as Record<string, unknown>;
const displayName = attrs.display_name as string | undefined;
const script = attrs.script as string | undefined;
if (displayName && script) {
byDisplayName[displayName] = script;
}
}
}
const scripts: Partial<ModuleScripts> = {};
for (const suffix of SCRIPT_SUFFIXES) {
const key = `Codex: ${suffix}`;
if (!(key in byDisplayName)) continue;
switch (suffix) {
case "Pre-Install Script":
scripts.pre_install = byDisplayName[key];
break;
case "Install Script":
scripts.install = byDisplayName[key];
break;
case "Post-Install Script":
scripts.post_install = byDisplayName[key];
break;
}
}
if (!scripts.install) {
throw new Error("install script not found in terraform state");
}
return scripts as ModuleScripts;
};
import dedent from "dedent";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
@@ -85,90 +33,36 @@ afterEach(async () => {
});
interface SetupProps {
skipAgentAPIMock?: boolean;
skipCodexMock?: boolean;
moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
}
const setup = async (
props?: SetupProps,
): Promise<{
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const projectDir = "/home/coder/project";
const moduleDir = path.resolve(import.meta.dir);
const state = await runTerraformApply(moduleDir, {
agent_id: "foo",
workdir: projectDir,
install_codex: "false",
...props?.moduleVariables,
});
const scripts = collectScripts(state);
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer("codercom/enterprise-node:latest");
registerCleanup(async () => {
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
console.log(`Not removing container ${id} in debug mode`);
return;
}
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/bash\nexit 0\n",
const { id } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
install_codex: props?.skipCodexMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
codex_model: "gpt-4-turbo",
workdir: "/home/coder",
...props?.moduleVariables,
},
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipCodexMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/codex",
content: await Bun.file(
path.join(moduleDir, "testdata", "codex-mock.sh"),
).text(),
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
});
}
return { id, coderEnvVars, scripts };
};
const runScripts = async (
id: string,
scripts: ModuleScripts,
env?: Record<string, string>,
) => {
const entries = env ? Object.entries(env) : [];
const envArgs =
entries.length > 0
? entries
.map(
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
)
.join(" && ") + " && "
: "";
const ordered: [string, string | undefined][] = [
["pre_install", scripts.pre_install],
["install", scripts.install],
["post_install", scripts.post_install],
];
for (const [name, script] of ordered) {
if (!script) continue;
const target = `/tmp/coder-utils-${name}.sh`;
await writeExecutable({
containerId: id,
filePath: target,
content: script,
});
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
if (resp.exitCode !== 0) {
console.log(`script ${name} failed:`);
console.log(resp.stdout);
console.log(resp.stderr);
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
}
}
return { id };
};
setDefaultTimeout(60 * 1000);
@@ -179,269 +73,413 @@ describe("codex", async () => {
});
test("happy-path", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain("Skipping Codex installation");
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("install-codex-version", async () => {
const version = "0.10.0";
const { id, coderEnvVars, scripts } = await setup({
const version_to_install = "0.10.0";
const { id } = await setup({
skipCodexMock: true,
moduleVariables: {
install_codex: "true",
codex_version: version,
codex_version: version_to_install,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain(version);
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
`cat /home/coder/.codex-module/install.log`,
]);
expect(resp.stdout).toContain(version_to_install);
});
test("openai-api-key", async () => {
const apiKey = "test-api-key-123";
const { coderEnvVars } = await setup({
test("check-latest-codex-version-works", async () => {
const { id } = await setup({
skipCodexMock: true,
skipAgentAPIMock: true,
moduleVariables: {
openai_api_key: apiKey,
install_codex: "true",
},
});
expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey);
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("base-config-toml", async () => {
const baseConfig = [
'sandbox_mode = "danger-full-access"',
'approval_policy = "never"',
'preferred_auth_method = "apikey"',
"",
"[custom_section]",
"new_feature = true",
].join("\n");
const { id, scripts } = await setup({
const baseConfig = dedent`
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
[custom_section]
new_feature = true
`.trim();
const { id } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts);
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain('sandbox_mode = "danger-full-access"');
expect(resp).toContain('preferred_auth_method = "apikey"');
expect(resp).toContain("[custom_section]");
expect(resp).toContain("[mcp_servers.Coder]");
});
test("additional-mcp-servers", async () => {
const additional = [
"[mcp_servers.GitHub]",
'command = "npx"',
'args = ["-y", "@modelcontextprotocol/server-github"]',
'type = "stdio"',
'description = "GitHub integration"',
].join("\n");
const { id, scripts } = await setup({
test("codex-api-key", async () => {
const apiKey = "test-api-key-123";
const { id } = await setup({
moduleVariables: {
mcp: additional,
openai_api_key: apiKey,
},
});
await runScripts(id, scripts);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.GitHub]");
expect(resp).toContain("GitHub integration");
});
await execModuleScript(id);
test("minimal-default-config", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain('preferred_auth_method = "apikey"');
expect(resp).not.toContain("model_provider");
expect(resp).not.toContain("[model_providers.");
expect(resp).not.toContain("model_reasoning_effort");
const resp = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(resp).toContain("OpenAI API Key: Provided");
});
test("pre-post-install-scripts", async () => {
const { id, scripts } = await setup({
const { id } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'codex-post-install-script'",
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
post_install_script: "#!/bin/bash\necho 'post-install-script'",
},
});
await runScripts(id, scripts);
await execModuleScript(id);
const preInstallLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log",
"/home/coder/.codex-module/pre_install.log",
);
expect(preInstallLog).toContain("codex-pre-install-script");
expect(preInstallLog).toContain("pre-install-script");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log",
"/home/coder/.codex-module/post_install.log",
);
expect(postInstallLog).toContain("codex-post-install-script");
expect(postInstallLog).toContain("post-install-script");
});
test("workdir-variable", async () => {
const workdir = "/home/coder/codex-test-folder";
const { id, scripts } = await setup({
const workdir = "/tmp/codex-test-workdir";
const { id } = await setup({
skipCodexMock: false,
moduleVariables: {
workdir,
},
});
await runScripts(id, scripts);
const installLog = await readFileContainer(
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
"/home/coder/.codex-module/install.log",
);
expect(installLog).toContain(workdir);
expect(resp).toContain(workdir);
});
test("codex-with-ai-gateway", async () => {
const { id, coderEnvVars, scripts } = await setup({
test("additional-mcp-servers", async () => {
const additional = dedent`
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
description = "GitHub integration"
[mcp_servers.FileSystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
type = "stdio"
description = "File system access"
`.trim();
const { id } = await setup({
moduleVariables: {
enable_ai_gateway: "true",
additional_mcp_servers: additional,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.GitHub]");
expect(resp).toContain("[mcp_servers.FileSystem]");
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("GitHub integration");
});
test("full-custom-config", async () => {
const baseConfig = dedent`
sandbox_mode = "read-only"
approval_policy = "untrusted"
preferred_auth_method = "chatgpt"
custom_setting = "test-value"
[advanced_settings]
timeout = 30000
debug = true
logging_level = "verbose"
`.trim();
const additionalMCP = dedent`
[mcp_servers.CustomTool]
command = "/usr/local/bin/custom-tool"
args = ["--serve", "--port", "8080"]
type = "stdio"
description = "Custom development tool"
[mcp_servers.DatabaseMCP]
command = "python"
args = ["-m", "database_mcp_server"]
type = "stdio"
description = "Database query interface"
`.trim();
const { id } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
additional_mcp_servers: additionalMCP,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
// Check base config
expect(resp).toContain('sandbox_mode = "read-only"');
expect(resp).toContain('preferred_auth_method = "chatgpt"');
expect(resp).toContain('custom_setting = "test-value"');
expect(resp).toContain("[advanced_settings]");
expect(resp).toContain('logging_level = "verbose"');
// Check MCP servers
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("[mcp_servers.CustomTool]");
expect(resp).toContain("[mcp_servers.DatabaseMCP]");
expect(resp).toContain("Custom development tool");
expect(resp).toContain("Database query interface");
});
test("minimal-default-config", async () => {
const { id } = await setup({
moduleVariables: {
// No base_config_toml or additional_mcp_servers - should use defaults
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
// Check default base config
expect(resp).toContain('sandbox_mode = "workspace-write"');
expect(resp).toContain('approval_policy = "never"');
expect(resp).toContain("[sandbox_workspace_write]");
expect(resp).toContain("network_access = true");
// Check only Coder MCP server is present
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("Report ALL tasks and statuses");
// Ensure no additional MCP servers
const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length;
expect(mcpServerCount).toBe(1);
});
test("codex-system-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
codex_system_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
expect(resp).toContain(prompt);
});
test("codex-system-prompt-skip-append-if-exists", async () => {
const prompt_1 = "This is a system prompt for Codex.";
const prompt_2 = "This is a system prompt for Goose.";
const prompt_3 = dedent`
This is a system prompt for Codex.
This is a system prompt for Gemini.
`.trim();
const pre_install_script = dedent`
#!/bin/bash
mkdir -p /home/coder/.codex
echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md
`.trim();
const { id } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_2,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
expect(resp).toContain(prompt_1);
expect(resp).toContain(prompt_2);
// Re-run with a prompt that already exists, it should not append again
const { id: id_2 } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_1,
},
});
await execModuleScript(id_2);
const resp_2 = await readFileContainer(
id_2,
"/home/coder/.codex/AGENTS.md",
);
expect(resp_2).toContain(prompt_1);
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
expect(count).toBe(1);
});
test("codex-ai-task-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
`cat /home/coder/.codex-module/agentapi-start.log`,
]);
expect(resp.stdout).toContain(prompt);
});
test("start-without-prompt", async () => {
const { id } = await setup({
moduleVariables: {
codex_system_prompt: "", // Explicitly disable system prompt
},
});
await execModuleScript(id);
const prompt = await execContainer(id, [
"ls",
"-l",
"/home/coder/.codex/AGENTS.md",
]);
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
test("codex-continue-capture-new-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
ai_prompt: "test task",
},
});
const workdir = "/home/coder";
const expectedSessionId = "019a1234-5678-9abc-def0-123456789012";
const sessionsDir = "/home/coder/.codex/sessions";
const sessionFile = `${sessionsDir}/${expectedSessionId}.jsonl`;
await execContainer(id, ["mkdir", "-p", sessionsDir]);
await execContainer(id, [
"bash",
"-c",
`echo '{"id":"${expectedSessionId}","cwd":"${workdir}","created":"2024-10-24T20:00:00Z","model":"gpt-4-turbo"}' > ${sessionFile}`,
]);
await execModuleScript(id);
await expectAgentAPIStarted(id);
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
const maxAttempts = 30;
let trackingFileContents = "";
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await execContainer(id, [
"bash",
"-c",
`cat ${trackingFile} 2>/dev/null || echo ""`,
]);
if (result.stdout.trim().length > 0) {
trackingFileContents = result.stdout;
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(trackingFileContents).toContain(`${workdir}|${expectedSessionId}`);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(startLog).toContain("Capturing new session ID");
expect(startLog).toContain("Session tracked");
expect(startLog).toContain(expectedSessionId);
});
test("codex-continue-resume-existing-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
ai_prompt: "test prompt",
},
});
const workdir = "/home/coder";
const mockSessionId = "019a1234-5678-9abc-def0-123456789012";
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
await execContainer(id, ["mkdir", "-p", "/home/coder/.codex-module"]);
await execContainer(id, [
"bash",
"-c",
`echo "${workdir}|${mockSessionId}" > ${trackingFile}`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.codex-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("Found existing task session");
expect(startLog.stdout).toContain(mockSessionId);
expect(startLog.stdout).toContain("Resuming existing session");
expect(startLog.stdout).toContain(
`Starting Codex with arguments: --model gpt-4-turbo resume ${mockSessionId}`,
);
expect(startLog.stdout).not.toContain("test prompt");
});
test("codex-with-aibridge", async () => {
const { id } = await setup({
moduleVariables: {
enable_aibridge: "true",
model_reasoning_effort: "none",
},
});
await runScripts(id, scripts, coderEnvVars);
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('model_provider = "aigateway"');
expect(configToml).toContain('model_reasoning_effort = "none"');
expect(configToml).toContain("[model_providers.aigateway]");
});
test("model-reasoning-effort-standalone", async () => {
const { id, scripts } = await setup({
moduleVariables: {
model_reasoning_effort: "high",
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain('model_reasoning_effort = "high"');
expect(configToml).not.toContain("model_provider");
});
test("workdir-trusted-project", async () => {
const workdir = "/home/coder/trusted-project";
const { id, scripts } = await setup({
moduleVariables: {
workdir,
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
expect(configToml).toContain(`[projects."${workdir}"]`);
expect(configToml).toContain('trust_level = "trusted"');
});
test("no-workdir-no-project-section", async () => {
const { id, scripts } = await setup({
moduleVariables: {
workdir: "",
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).not.toContain("[projects.");
});
test("ai-gateway-with-custom-base-config", async () => {
const baseConfig = [
'sandbox_mode = "danger-full-access"',
'model_provider = "aigateway"',
].join("\n");
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
enable_ai_gateway: "true",
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts, coderEnvVars);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('model_provider = "aigateway"');
expect(configToml).toContain("[model_providers.aigateway]");
});
test("ai-gateway-custom-config-no-duplicate-provider", async () => {
const baseConfig = [
'model_provider = "aigateway"',
"",
"[model_providers.aigateway]",
'name = "Custom AI Bridge"',
'base_url = "https://custom.example.com"',
'env_key = "CODER_AIBRIDGE_SESSION_TOKEN"',
'wire_api = "responses"',
].join("\n");
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
enable_ai_gateway: "true",
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts, coderEnvVars);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
const matches = configToml.match(/\[model_providers\.aigateway\]/g) || [];
expect(matches.length).toBe(1);
expect(configToml).toContain("Custom AI Bridge");
});
test("install-codex-latest", async () => {
const { id, coderEnvVars, scripts } = await setup({
skipCodexMock: true,
moduleVariables: {
install_codex: "true",
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain("Installed Codex CLI");
});
test("custom-config-drops-reasoning-effort", async () => {
const baseConfig = [
'sandbox_mode = "danger-full-access"',
'preferred_auth_method = "apikey"',
].join("\n");
const { id, scripts } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
model_reasoning_effort: "high",
},
});
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('sandbox_mode = "danger-full-access"');
expect(configToml).not.toContain("model_reasoning_effort");
});
});
+194 -95
View File
@@ -18,6 +18,18 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -26,8 +38,106 @@ variable "icon" {
variable "workdir" {
type = string
description = "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted project in Codex config.toml."
default = null
description = "The folder to run Codex in."
}
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI via AgentAPI"
default = true
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = false
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Codex"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Codex"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Codex CLI"
}
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_aibridge && length(var.openai_api_key) > 0)
error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
validation {
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
variable "install_codex" {
type = bool
description = "Whether to install Codex."
default = true
}
variable "codex_version" {
type = string
description = "The version of Codex to install."
default = "" # empty string means the latest available version
}
variable "base_config_toml" {
type = string
description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md"
default = ""
}
variable "additional_mcp_servers" {
type = string
description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section."
default = ""
}
variable "openai_api_key" {
type = string
description = "OpenAI API key for Codex CLI"
default = ""
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.8"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "gpt-5.2-codex"
}
variable "pre_install_script" {
@@ -42,127 +152,116 @@ variable "post_install_script" {
default = null
}
variable "install_codex" {
variable "ai_prompt" {
type = string
description = "Initial task prompt for Codex CLI when launched via Tasks"
default = ""
}
variable "continue" {
type = bool
description = "Whether to install Codex."
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
default = true
}
variable "codex_version" {
variable "codex_system_prompt" {
type = string
description = "The version of Codex to install."
default = "latest"
}
variable "openai_api_key" {
type = string
description = "OpenAI API key for Codex CLI."
sensitive = true
default = ""
}
variable "base_config_toml" {
type = string
description = <<-EOT
Complete base TOML configuration for Codex (without mcp_servers section).
When empty, the module generates a minimal default:
preferred_auth_method = "apikey"
# model_provider = "aigateway" (sets the default profile, when enable_ai_gateway = true)
# model_reasoning_effort = "<value>" (sets the reasoning effort, when model_reasoning_effort is set)
[projects."<workdir>"] (when workdir is set)
trust_level = "trusted"
When non-empty, the value is written verbatim as the base of config.toml;
mcp and AI Gateway sections are still appended after it.
Note: model_reasoning_effort and workdir trust are only applied in the
default config. Include them in your custom config if needed.
EOT
default = ""
}
variable "mcp" {
type = string
description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml."
default = ""
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the model. One of: none, minimal, low, medium, high, xhigh. See https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = ""
validation {
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, minimal, low, medium, high, xhigh."
}
}
variable "enable_ai_gateway" {
type = bool
description = "Use AI Gateway for Codex. https://coder.com/docs/ai-coder/ai-gateway"
default = false
validation {
condition = !(var.enable_ai_gateway && length(var.openai_api_key) > 0)
error_message = "openai_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials."
}
description = "System instructions written to AGENTS.md in the ~/.codex directory"
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
}
resource "coder_env" "openai_api_key" {
count = var.openai_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "OPENAI_API_KEY"
value = var.openai_api_key
}
# Authenticates the client against Coder's AI Gateway using the workspace
# owner's session token. Referenced by config.toml model_providers.aigateway.
resource "coder_env" "ai_gateway_session_token" {
count = var.enable_ai_gateway ? 1 : 0
resource "coder_env" "coder_aibridge_session_token" {
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token
}
locals {
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aigateway]
name = "AI Gateway"
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_INSTALL = tostring(var.install_codex)
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort
ARG_OPENAI_API_KEY = var.openai_api_key != "" ? base64encode(var.openai_api_key) : ""
})
module_dir_name = ".coder-modules/coder-labs/codex"
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
agent_id = var.agent_id
module_directory = "$HOME/${local.module_dir_name}"
display_name_prefix = "Codex"
icon = var.icon
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_CONTINUE='${var.continue}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/start.sh
EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
ARG_AIBRIDGE_CONFIG='${base64encode(var.enable_aibridge ? local.aibridge_config : "")}' \
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
}
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
output "task_app_id" {
value = module.agentapi.task_app_id
}
@@ -1,185 +0,0 @@
run "test_codex_basic" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.install_codex == true
error_message = "install_codex should default to true"
}
}
run "test_codex_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = coder_env.openai_api_key[0].value == "test-key"
error_message = "OpenAI API key should be set correctly"
}
}
run "test_codex_custom_options" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder/project"
icon = "/icon/custom.svg"
codex_version = "0.128.0"
}
assert {
condition = length(output.scripts) > 0
error_message = "scripts output should be non-empty with custom options"
}
}
run "test_ai_gateway_enabled" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = true
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = coder_env.ai_gateway_session_token[0].name == "CODER_AIBRIDGE_SESSION_TOKEN"
error_message = "CODER_AIBRIDGE_SESSION_TOKEN should be set"
}
assert {
condition = coder_env.ai_gateway_session_token[0].value == data.coder_workspace_owner.me.session_token
error_message = "Session token should use workspace owner's token"
}
assert {
condition = length(coder_env.openai_api_key) == 0
error_message = "OPENAI_API_KEY should not be created when ai_gateway is enabled"
}
}
run "test_ai_gateway_validation_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = true
openai_api_key = "test-key"
}
expect_failures = [
var.enable_ai_gateway,
]
}
run "test_ai_gateway_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_ai_gateway = false
openai_api_key = "test-key-xyz"
}
assert {
condition = coder_env.openai_api_key[0].value == "test-key-xyz"
error_message = "OPENAI_API_KEY should use the provided API key"
}
assert {
condition = length(coder_env.ai_gateway_session_token) == 0
error_message = "Session token should not be set when ai_gateway is disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = length(coder_env.openai_api_key) == 0
error_message = "OPENAI_API_KEY should not be created when no API key is provided"
}
}
run "test_codex_with_scripts" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
pre_install_script = "echo 'Pre-install script'"
post_install_script = "echo 'Post-install script'"
}
assert {
condition = length(output.scripts) == 3
error_message = "scripts output should have 3 entries when pre/post are configured"
}
}
run "test_script_outputs_install_only" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-labs-codex-install_script"
error_message = "scripts output should list only the install script when pre/post are not configured"
}
}
run "test_script_outputs_with_pre_and_post" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
pre_install_script = "echo pre"
post_install_script = "echo post"
}
assert {
condition = output.scripts == ["coder-labs-codex-pre_install_script", "coder-labs-codex-install_script", "coder-labs-codex-post_install_script"]
error_message = "scripts output should list pre_install, install, post_install in run order"
}
}
run "test_workdir_optional" {
command = plan
variables {
agent_id = "test-agent"
}
assert {
condition = length(output.scripts) == 1
error_message = "scripts output should have install script even without workdir"
}
}
@@ -0,0 +1,208 @@
#!/bin/bash
source "$HOME"/.bashrc
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
set -o errexit
set -o pipefail
set -o nounset
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_AIBRIDGE_CONFIG=$(echo -n "$ARG_AIBRIDGE_CONFIG" | base64 -d)
echo "=== Codex Module Configuration ==="
printf "Install Codex: %s\n" "$ARG_INSTALL"
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================"
set +o nounset
function install_node() {
if ! command_exists npm; then
printf "npm not found, checking for Node.js installation...\n"
if ! command_exists node; then
printf "Node.js not found, installing Node.js via NVM...\n"
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
nvm install --lts
nvm use --lts
nvm alias default node
printf "Node.js installed: %s\n" "$(node --version)"
printf "npm installed: %s\n" "$(npm --version)"
else
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
exit 1
fi
fi
}
function install_codex() {
if [ "${ARG_INSTALL}" = "true" ]; then
install_node
if ! command_exists nvm; then
printf "which node: %s\n" "$(which node)"
printf "which npm: %s\n" "$(which npm)"
mkdir -p "$HOME"/.npm-global
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
fi
fi
printf "%s Installing Codex CLI\n" "${BOLD}"
if [ -n "$ARG_CODEX_VERSION" ]; then
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
else
npm install -g "@openai/codex"
fi
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
fi
}
write_minimal_default_config() {
local config_path="$1"
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
[sandbox_workspace_write]
network_access = true
EOF
}
append_mcp_servers_section() {
local config_path="$1"
if [ "${ARG_REPORT_TASKS}" == "false" ]; then
ARG_CODER_MCP_APP_STATUS_SLUG=""
CODER_MCP_AI_AGENTAPI_URL=""
else
CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
fi
cat << EOF >> "$config_path"
# MCP Servers Configuration
[mcp_servers.Coder]
command = "coder"
args = ["exp", "mcp", "server"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
type = "stdio"
EOF
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
printf "Adding additional MCP servers\n"
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
fi
}
append_aibridge_config_section() {
local config_path="$1"
if [ -n "$ARG_AIBRIDGE_CONFIG" ]; then
printf "Adding AI Bridge configuration\n"
echo -e "\n# AI Bridge Configuration\n$ARG_AIBRIDGE_CONFIG" >> "$config_path"
fi
}
function populate_config_toml() {
CONFIG_PATH="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$CONFIG_PATH")"
if [ -n "$ARG_BASE_CONFIG_TOML" ]; then
printf "Using provided base configuration\n"
echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH"
else
printf "Using minimal default configuration\n"
write_minimal_default_config "$CONFIG_PATH"
fi
append_mcp_servers_section "$CONFIG_PATH"
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled\n"
append_aibridge_config_section "$CONFIG_PATH"
fi
}
function add_instruction_prompt_if_exists() {
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
AGENTS_PATH="$HOME/.codex/AGENTS.md"
printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}"
mkdir -p "$HOME/.codex"
if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
else
printf "Appending instruction prompt to AGENTS.md in .codex directory\n"
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}"
fi
if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
else
printf "AGENTS.md instruction prompt is not set.\n"
fi
}
function add_auth_json() {
AUTH_JSON_PATH="$HOME/.codex/auth.json"
mkdir -p "$(dirname "$AUTH_JSON_PATH")"
AUTH_JSON=$(
cat << EOF
{
"OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}"
}
EOF
)
echo "$AUTH_JSON" > "$AUTH_JSON_PATH"
}
install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
add_auth_json
fi
@@ -1,195 +0,0 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
ARG_OPENAI_API_KEY=$(echo -n '${ARG_OPENAI_API_KEY}' | base64 -d)
echo "--------------------------------"
printf "codex_version: %s\n" "$${ARG_CODEX_VERSION}"
printf "workdir: %s\n" "$${ARG_WORKDIR}"
printf "enable_ai_gateway: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "install_codex: %s\n" "$${ARG_INSTALL}"
printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}"
echo "--------------------------------"
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$${profile}" ]; then
if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then
echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}"
echo "Added $${path_dir} to $${profile}"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$${fish_config}" ]; then
if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then
echo "fish_add_path $${path_dir}" >> "$${fish_config}"
echo "Added $${path_dir} to $${fish_config}"
fi
fi
}
function ensure_codex_in_path() {
local CODEX_BIN=""
if command -v codex > /dev/null 2>&1; then
CODEX_BIN=$(command -v codex)
elif [ -x "$HOME/.npm-global/bin/codex" ]; then
CODEX_BIN="$HOME/.npm-global/bin/codex"
fi
if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then
echo "Warning: Could not find codex binary after install"
return
fi
local CODEX_DIR
CODEX_DIR=$(dirname "$${CODEX_BIN}")
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/codex" ]; then
ln -s "$${CODEX_BIN}" "$${CODER_SCRIPT_BIN_DIR}/codex"
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/codex -> $${CODEX_BIN}"
fi
add_path_to_shell_profiles "$${CODEX_DIR}"
}
function install_codex() {
if [ "$${ARG_INSTALL}" != "true" ]; then
echo "Skipping Codex installation as per configuration."
ensure_codex_in_path
return
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
. "$NVM_DIR/nvm.sh"
fi
# Detect a package manager for global installs.
if command_exists npm; then
PKG_INSTALL="npm install -g"
if ! command_exists nvm; then
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
fi
elif command_exists pnpm; then
PKG_INSTALL="pnpm add -g"
elif command_exists bun; then
PKG_INSTALL="bun add -g"
else
echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false."
exit 1
fi
printf "%s Installing Codex CLI\n" "$${BOLD}"
if [ -n "$${ARG_CODEX_VERSION}" ]; then
$PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}"
else
$PKG_INSTALL "@openai/codex"
fi
printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)"
ensure_codex_in_path
}
function write_minimal_default_config() {
local config_path="$1"
local optional_config=""
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then
optional_config='model_provider = "aigateway"'
fi
if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then
optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\""
fi
cat << EOF > "$${config_path}"
preferred_auth_method = "apikey"
$${optional_config}
EOF
if [ -n "$${ARG_WORKDIR}" ]; then
cat << EOF >> "$${config_path}"
[projects."$${ARG_WORKDIR}"]
trust_level = "trusted"
EOF
fi
}
function populate_config_toml() {
local config_path="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$${config_path}")"
if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then
printf "Using provided base configuration\n"
echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}"
else
printf "Using minimal default configuration\n"
write_minimal_default_config "$${config_path}"
fi
if [ -n "$${ARG_MCP}" ]; then
printf "Adding MCP servers\n"
echo "$${ARG_MCP}" >> "$${config_path}"
fi
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then
if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then
printf "Adding AI Gateway configuration\n"
echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}"
else
printf "AI Gateway provider already defined in config, skipping append\n"
fi
fi
}
function setup_workdir() {
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
echo "Creating workdir: $${ARG_WORKDIR}"
mkdir -p "$${ARG_WORKDIR}"
fi
}
function add_auth_json() {
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] || [ -z "$${ARG_OPENAI_API_KEY}" ]; then
return
fi
local auth_path="$HOME/.codex/auth.json"
mkdir -p "$(dirname "$${auth_path}")"
cat << EOF > "$${auth_path}"
{
"auth_mode": "apikey",
"OPENAI_API_KEY": "$${ARG_OPENAI_API_KEY}"
}
EOF
echo "Seeded auth.json with API key"
}
install_codex
populate_config_toml
setup_workdir
add_auth_json
@@ -0,0 +1,223 @@
#!/bin/bash
source "$HOME"/.bashrc
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" > /dev/null 2>&1
}
if [ -f "$HOME/.nvm/nvm.sh" ]; then
source "$HOME"/.nvm/nvm.sh
else
export PATH="$HOME/.npm-global/bin:$PATH"
fi
printf "Version: %s\n" "$(codex --version)"
set -o nounset
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
ARG_CONTINUE=${ARG_CONTINUE:-true}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
echo "=== Codex Launch Configuration ==="
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Continue Sessions: %s\n" "$ARG_CONTINUE"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================"
set +o nounset
SESSION_TRACKING_FILE="$HOME/.codex-module/.codex-task-session"
find_session_for_directory() {
local target_dir="$1"
if [ ! -f "$SESSION_TRACKING_FILE" ]; then
return 1
fi
local session_id
session_id=$(grep "^$target_dir|" "$SESSION_TRACKING_FILE" | cut -d'|' -f2 | head -1)
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
return 1
}
store_session_mapping() {
local dir="$1"
local session_id="$2"
mkdir -p "$(dirname "$SESSION_TRACKING_FILE")"
if [ -f "$SESSION_TRACKING_FILE" ]; then
grep -v "^$dir|" "$SESSION_TRACKING_FILE" > "$SESSION_TRACKING_FILE.tmp" 2> /dev/null || true
mv "$SESSION_TRACKING_FILE.tmp" "$SESSION_TRACKING_FILE"
fi
echo "$dir|$session_id" >> "$SESSION_TRACKING_FILE"
}
find_recent_session_file() {
local target_dir="$1"
local sessions_dir="$HOME/.codex/sessions"
if [ ! -d "$sessions_dir" ]; then
return 1
fi
local latest_file=""
local latest_time=0
while IFS= read -r session_file; do
local file_time
file_time=$(stat -c %Y "$session_file" 2> /dev/null || stat -f %m "$session_file" 2> /dev/null || echo "0")
local first_line
first_line=$(head -n 1 "$session_file" 2> /dev/null)
local session_cwd
session_cwd=$(echo "$first_line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4)
if [ "$session_cwd" = "$target_dir" ] && [ "$file_time" -gt "$latest_time" ]; then
latest_file="$session_file"
latest_time="$file_time"
fi
done < <(find "$sessions_dir" -type f -name "*.jsonl" 2> /dev/null)
if [ -n "$latest_file" ]; then
local first_line
first_line=$(head -n 1 "$latest_file")
local session_id
session_id=$(echo "$first_line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
fi
return 1
}
wait_for_session_file() {
local target_dir="$1"
local max_attempts=20
local attempt=0
while [ $attempt -lt $max_attempts ]; do
local session_id
session_id=$(find_recent_session_file "$target_dir" 2> /dev/null || echo "")
if [ -n "$session_id" ]; then
echo "$session_id"
return 0
fi
sleep 0.5
attempt=$((attempt + 1))
done
return 1
}
validate_codex_installation() {
if command_exists codex; then
printf "Codex is installed\n"
else
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
exit 1
fi
}
setup_workdir() {
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
}
build_codex_args() {
CODEX_ARGS=()
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
existing_session=$(find_session_for_directory "$ARG_CODEX_START_DIRECTORY" 2> /dev/null || echo "")
if [ -n "$existing_session" ]; then
printf "Found existing task session for this directory: %s\n" "$existing_session"
printf "Resuming existing session...\n"
CODEX_ARGS+=("resume" "$existing_session")
else
printf "No existing task session found for this directory\n"
printf "Starting new task session...\n"
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi
CODEX_ARGS+=("$PROMPT")
fi
fi
else
printf "Continue disabled, starting fresh session\n"
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using Coder.coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi
CODEX_ARGS+=("$PROMPT")
fi
fi
}
capture_session_id() {
if [ "$ARG_CONTINUE" = "true" ] && [ -z "$existing_session" ]; then
printf "Capturing new session ID...\n"
new_session=$(wait_for_session_file "$ARG_CODEX_START_DIRECTORY" || echo "")
if [ -n "$new_session" ]; then
store_session_mapping "$ARG_CODEX_START_DIRECTORY" "$new_session"
printf "✓ Session tracked: %s\n" "$new_session"
printf "This session will be automatically resumed on next restart\n"
else
printf "⚠ Could not capture session ID after 10s timeout\n"
fi
fi
}
start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
capture_session_id
}
validate_codex_installation
setup_workdir
build_codex_args
start_codex
+31 -2
View File
@@ -1,9 +1,38 @@
#!/bin/bash
# Handle --version flag
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "codex version v1.0.0"
exit 0
fi
echo "codex invoked with: $*"
exit 0
set -e
SESSION_ID=""
IS_RESUME=false
while [[ $# -gt 0 ]]; do
case $1 in
resume)
IS_RESUME=true
SESSION_ID="$2"
shift 2
;;
*)
shift
;;
esac
done
if [ "$IS_RESUME" = false ]; then
SESSION_ID="019a1234-5678-9abc-def0-123456789012"
echo "Created new session: $SESSION_ID"
else
echo "Resuming session: $SESSION_ID"
fi
while true; do
echo "$(date) - codex-mock (session: $SESSION_ID)"
sleep 15
done
+6 -39
View File
@@ -3,7 +3,7 @@ display_name: Copilot CLI
description: GitHub Copilot CLI agent for AI-powered terminal assistance
icon: ../../../../.icons/github.svg
verified: false
tags: [agent, copilot, ai, github, tasks, aibridge]
tags: [agent, copilot, ai, github, tasks]
---
# Copilot
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
version = "0.3.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
@@ -164,39 +164,6 @@ module "copilot" {
}
```
### Usage with AI Bridge Proxy
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance.
The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url
aibridge_proxy_cert_path = module.aibridge-proxy.cert_path
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication.
> [!IMPORTANT]
> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment.
> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time.
## Authentication
The module supports multiple authentication methods (in priority order):
@@ -117,23 +117,18 @@ run "copilot_model_not_created_for_default" {
}
}
run "copilot_model_accepts_custom_model" {
run "model_validation_accepts_valid_models" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "o3-pro"
copilot_model = "gpt-5"
}
assert {
condition = var.copilot_model == "o3-pro"
error_message = "copilot_model should accept any model string"
}
assert {
condition = length(resource.coder_env.copilot_model) == 1
error_message = "copilot_model env var should be created for non-default model"
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
error_message = "Model should be one of the valid options"
}
}
@@ -239,116 +234,3 @@ run "app_slug_is_consistent" {
error_message = "module_dir_name should be '.copilot-module'"
}
}
run "aibridge_proxy_defaults" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_aibridge_proxy == false
error_message = "enable_aibridge_proxy should default to false"
}
assert {
condition = var.aibridge_proxy_auth_url == null
error_message = "aibridge_proxy_auth_url should default to null"
}
assert {
condition = var.aibridge_proxy_cert_path == null
error_message = "aibridge_proxy_cert_path should default to null"
}
}
run "aibridge_proxy_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge-proxy"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com"
error_message = "AI Bridge Proxy auth URL should match the input variable"
}
assert {
condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "AI Bridge Proxy cert path should match the input variable"
}
}
run "aibridge_proxy_validation_missing_proxy_auth_url" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = ""
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_validation_missing_cert_path" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = ""
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_with_copilot_config" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "gpt-5"
github_token = "ghp_test123"
allow_all_tools = true
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = length(resource.coder_env.github_token) == 1
error_message = "github_token environment variable should be set alongside proxy"
}
assert {
condition = length(resource.coder_env.copilot_model) == 1
error_message = "copilot_model environment variable should be set alongside proxy"
}
}
+6 -34
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.9"
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
@@ -33,8 +33,12 @@ variable "github_token" {
variable "copilot_model" {
type = string
description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used."
description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5."
default = "claude-sonnet-4.5"
validation {
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5."
}
}
variable "copilot_config" {
@@ -169,35 +173,6 @@ variable "post_install_script" {
default = null
}
variable "enable_aibridge_proxy" {
type = bool
description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy"
default = false
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0)
error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true."
}
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0)
error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true."
}
}
variable "aibridge_proxy_auth_url" {
type = string
description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module."
default = null
sensitive = true
}
variable "aibridge_proxy_cert_path" {
type = string
description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -304,9 +279,6 @@ module "agentapi" {
ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
ARG_RESUME_SESSION='${var.resume_session}' \
ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \
ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \
ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \
/tmp/start.sh
EOT
@@ -22,9 +22,6 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-}
ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-}
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true}
ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false}
ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-}
ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-}
validate_copilot_installation() {
if ! command_exists copilot; then
@@ -121,48 +118,6 @@ setup_github_authentication() {
return 0
}
setup_aibridge_proxy() {
if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then
return 0
fi
echo "Setting up AI Bridge Proxy..."
# Wait for the aibridge-proxy module to finish.
# Uses startup coordination to block until aibridge-proxy-setup signals completion.
if command -v coder > /dev/null 2>&1; then
coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true
coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true
trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided."
exit 1
fi
if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided."
exit 1
fi
if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH."
echo " Ensure the aibridge-proxy module has successfully completed setup."
exit 1
fi
# Set proxy environment variables scoped to this process tree only.
# These are inherited by the agentapi/copilot process below,
# but do not affect other workspace processes, avoiding routing
# unnecessary traffic through the proxy.
export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL"
export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH"
echo "✓ AI Bridge Proxy configured"
echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH"
}
start_agentapi() {
echo "Starting in directory: $ARG_WORKDIR"
cd "$ARG_WORKDIR"
@@ -202,6 +157,5 @@ start_agentapi() {
}
setup_github_authentication
setup_aibridge_proxy
validate_copilot_installation
start_agentapi
+4 -20
View File
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
@@ -105,22 +105,6 @@ module "gemini" {
You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER.
EOT
pre_install_script = <<-EOT
#!/bin/bash
set -e
echo "Installing Node.js via NodeSource..."
sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
sudo apt-get install -y nodejs
echo "Node version: $(node -v)"
echo "npm version: $(npm -v)"
echo "Node install complete."
EOT
}
```
@@ -134,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform:
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
+8 -2
View File
@@ -148,16 +148,22 @@ locals {
base_extensions = <<-EOT
{
"coder": {
"command": "coder",
"args": [
"exp",
"mcp",
"server"
],
"command": "coder",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"enabled": true,
"env": {
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
}
},
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
}
}
EOT
@@ -17,7 +17,6 @@ echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
printf "install: %s\n" "$ARG_INSTALL"
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
echo "--------------------------------"
set +o nounset
@@ -141,25 +140,6 @@ function add_system_prompt_if_exists() {
fi
}
function patch_coder_mcp_command() {
CODER_BIN=$(which coder)
SETTINGS_PATH="$HOME/.gemini/settings.json"
if [ -z "$CODER_BIN" ]; then
printf "Warning: could not find coder binary, MCP command path not patched.\n"
return
fi
printf "Patching coder MCP command path to: %s\n" "$CODER_BIN"
TMP_SETTINGS=$(mktemp)
jq --arg bin "$CODER_BIN" \
'.mcpServers.coder.command = $bin' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "Patch complete.\n"
}
function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
@@ -169,5 +149,4 @@ function configure_mcp() {
install_gemini
populate_settings_json
add_system_prompt_if_exists
patch_coder_mcp_command
configure_mcp
@@ -13,7 +13,7 @@ Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for in
```tf
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
}
@@ -34,7 +34,7 @@ resource "coder_ai_task" "task" {
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -89,7 +89,7 @@ Run OpenCode as a command-line tool without web interface or task reporting:
```tf
module "opencode" {
source = "registry.coder.com/coder-labs/opencode/coder"
version = "0.1.2"
version = "0.1.1"
agent_id = coder_agent.main.id
workdir = "/home/coder"
report_tasks = false
@@ -39,7 +39,7 @@ install_opencode() {
if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then
curl -fsSL https://opencode.ai/install | bash
else
curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash
VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash
fi
export PATH=/home/coder/.opencode/bin:$PATH
printf "Opencode location: %s\n" "$(which opencode)"
@@ -1,57 +0,0 @@
---
display_name: ttyd
description: Share a terminal command over the web via a Coder app
icon: ../../../../.icons/terminal.svg
verified: true
tags: [terminal, web, ttyd]
---
# ttyd
Run any command and expose it as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). Each connection spawns a new process for the configured command. The terminal is accessible as a Coder app in the workspace UI.
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "bash"
}
```
## Examples
### Custom command
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
display_name = "Shared Terminal"
command = "tmux new-session -A -s main"
share = "authenticated"
}
```
### Readonly with custom ttyd options
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "tail -f /var/log/app.log"
writable = false
additional_args = "-t fontSize=18"
}
```
## Session Behavior
By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process.
To get a **persistent, shared session** that survives tab closes and allows multiple viewers, use tmux as the command (see example above). This requires tmux to be installed in the workspace image.
@@ -1,112 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
type scriptOutput,
testRequiredVariables,
} from "~test";
function testBaseLine(output: scriptOutput) {
expect(output.exitCode).toBe(0);
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Installing ttyd");
expect(stdout).toContain("Installation complete!");
expect(stdout).toContain("Starting ttyd in background...");
}
describe("ttyd", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
it("runs with bash", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with custom command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "htop",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("htop");
}, 30000);
it("runs with writable=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
writable: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
agent_name: "main",
subdomain: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with additional_args", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
additional_args: "-t fontSize=18",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("fontSize=18");
}, 30000);
});
-165
View File
@@ -1,165 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "ttyd"
}
variable "display_name" {
type = string
description = "The display name for the ttyd application."
default = "Web Terminal"
}
variable "port" {
type = number
description = "The port to run ttyd on."
default = 7681
}
variable "command" {
type = string
description = "The command for ttyd to run (e.g., bash, fish, htop)."
}
variable "writable" {
type = bool
description = "Allow clients to write to the terminal."
default = true
}
variable "max_clients" {
type = number
description = "Maximum number of concurrent clients (0 for unlimited)."
default = 0
}
variable "additional_args" {
type = string
description = "Additional arguments to pass to ttyd."
default = ""
}
variable "log_path" {
type = string
description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)."
default = ""
}
variable "ttyd_version" {
type = string
description = "The version of ttyd to install."
default = "1.7.7"
}
variable "share" {
type = string
description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)."
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are "tab" and "slim-window" (default).
"tab" opens in a new tab in the same browser window.
"slim-window" opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
resource "coder_script" "ttyd" {
agent_id = var.agent_id
display_name = var.display_name
icon = "/icon/terminal.svg"
script = templatefile("${path.module}/run.sh", {
PORT = var.port,
COMMAND = var.command,
WRITABLE = var.writable,
MAX_CLIENTS = var.max_clients,
ADDITIONAL_ARGS = var.additional_args,
LOG_PATH = local.log_path,
VERSION = var.ttyd_version,
BASE_PATH = local.base_path,
})
run_on_start = true
}
resource "coder_app" "ttyd" {
count = var.command != "" ? 1 : 0
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}${local.base_path}/"
icon = "/icon/terminal.svg"
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
healthcheck {
url = "http://localhost:${var.port}${local.base_path}/token"
interval = 5
threshold = 6
}
}
locals {
base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log"
}
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BOLD='\033[[0;1m'
if command -v ttyd &> /dev/null; then
printf "%sFound existing ttyd installation\n\n" "$${BOLD}"
else
printf "%sInstalling ttyd %s\n\n" "$${BOLD}" "${VERSION}"
ARCH=$(uname -m)
# shellcheck disable=SC2195
case "$${ARCH}" in
x86_64) BINARY="ttyd.x86_64" ;;
aarch64) BINARY="ttyd.aarch64" ;;
armv7l) BINARY="ttyd.armhf" ;;
armv6l) BINARY="ttyd.arm" ;;
*)
echo "ERROR: Unsupported architecture: $${ARCH}" >&2
exit 1
;;
esac
BIN_DIR="$${HOME}/.local/bin"
mkdir -p "$${BIN_DIR}"
export PATH="$${BIN_DIR}:$${PATH}"
TTYD_BIN="$${BIN_DIR}/ttyd"
LOCK_DIR="/tmp/ttyd-install.lock"
if [[ ! -f "$${TTYD_BIN}" ]]; then
if mkdir "$${LOCK_DIR}" 2> /dev/null; then
if [[ ! -f "$${TTYD_BIN}" ]]; then
DOWNLOAD_URL="https://github.com/tsl0922/ttyd/releases/download/${VERSION}/$${BINARY}"
printf "Downloading ttyd from %s\n" "$${DOWNLOAD_URL}"
curl -fsSL "$${DOWNLOAD_URL}" -o "$${TTYD_BIN}.tmp"
chmod +x "$${TTYD_BIN}.tmp"
mv "$${TTYD_BIN}.tmp" "$${TTYD_BIN}"
fi
rmdir "$${LOCK_DIR}" 2> /dev/null || true
else
printf "Waiting for ttyd installation to complete...\n"
while [[ -d "$${LOCK_DIR}" ]] && [[ ! -f "$${TTYD_BIN}" ]]; do
sleep 0.5
done
fi
fi
printf "Installation complete!\n\n"
fi
if [[ -z "${COMMAND}" ]]; then
printf "No command specified, skipping ttyd startup.\n"
exit 0
fi
ARGS="-p ${PORT}"
if [[ "${WRITABLE}" = "true" ]]; then
ARGS="$${ARGS} -W"
fi
if [[ "${MAX_CLIENTS}" -gt 0 ]] 2> /dev/null; then
ARGS="$${ARGS} -m ${MAX_CLIENTS}"
fi
if [[ -n "${BASE_PATH}" ]]; then
ARGS="$${ARGS} -b ${BASE_PATH}"
fi
if [[ -n "${ADDITIONAL_ARGS}" ]]; then
ARGS="$${ARGS} ${ADDITIONAL_ARGS}"
fi
TTYD_LOG_PATH="${LOG_PATH}"
TTYD_LOG_PATH="$${TTYD_LOG_PATH/#\~/$${HOME}}"
TTYD_LOG_DIR="$${TTYD_LOG_PATH%/*}"
mkdir -p "$${TTYD_LOG_DIR}"
printf "Starting ttyd in background...\n"
printf "Running: ttyd %s -- %s\n\n" "$${ARGS}" "${COMMAND}"
# shellcheck disable=SC2086
ttyd $${ARGS} -- ${COMMAND} >> "$${TTYD_LOG_PATH}" 2>&1 &
printf "Logs at %s\n" "$${TTYD_LOG_PATH}"
@@ -0,0 +1,65 @@
---
display_name: Agent Helper
description: Building block for modules that need orchestrated script execution
icon: ../../../../.icons/coder.svg
verified: false
tags: [internal, library]
---
# Agent Helper
> [!CAUTION]
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
> [!NOTE]
>
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
```tf
module "agent_helper" {
source = "registry.coder.com/coder/agent-helper/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = "myagent"
module_dir_name = ".my-module"
pre_install_script = <<-EOT
#!/bin/bash
echo "Running pre-install tasks..."
# Your pre-install logic here
EOT
install_script = <<-EOT
#!/bin/bash
echo "Installing dependencies..."
# Your install logic here
EOT
post_install_script = <<-EOT
#!/bin/bash
echo "Running post-install configuration..."
# Your post-install logic here
EOT
start_script = <<-EOT
#!/bin/bash
echo "Starting the application..."
# Your start logic here
EOT
}
```
## Execution Order
The module orchestrates scripts in the following order:
1. **Log File Creation** - Creates module directory and log files
2. **Pre-Install Script** (optional) - Runs before installation
3. **Install Script** - Main installation
4. **Post-Install Script** (optional) - Runs after installation
5. **Start Script** - Starts the application
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
@@ -0,0 +1,13 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("agent-helper", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "test-agent",
module_dir_name: ".test-module",
start_script: "echo 'start'",
});
});
+190
View File
@@ -0,0 +1,190 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the agent used by AgentAPI."
default = null
}
variable "install_script" {
type = string
description = "Script to install the agent used by AgentAPI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the agent used by AgentAPI."
default = null
}
variable "start_script" {
type = string
description = "Script that starts AgentAPI."
}
variable "agent_name" {
type = string
description = "The name of the agent. This is used to construct unique script names for the experiment sync."
}
variable "module_dir_name" {
type = string
description = "The name of the module directory."
}
locals {
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
encoded_start_script = base64encode(var.start_script)
pre_install_script_name = "${var.agent_name}-pre_install_script"
install_script_name = "${var.agent_name}-install_script"
post_install_script_name = "${var.agent_name}-post_install_script"
start_script_name = "${var.agent_name}-start_script"
module_dir_path = "$HOME/${var.module_dir_name}"
pre_install_path = "${local.module_dir_path}/pre_install.sh"
install_path = "${local.module_dir_path}/install.sh"
post_install_path = "${local.module_dir_path}/post_install.sh"
start_path = "${local.module_dir_path}/start.sh"
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
install_log_path = "${local.module_dir_path}/install.log"
post_install_log_path = "${local.module_dir_path}/post_install.log"
start_log_path = "${local.module_dir_path}/start.log"
}
resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id
display_name = "Pre-Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
coder exp sync start ${local.pre_install_script_name}
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
chmod +x ${local.pre_install_path}
${local.pre_install_path} > ${local.pre_install_log_path} 2>&1
EOT
}
resource "coder_script" "install_script" {
agent_id = var.agent_id
display_name = "Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.install_script_name}' EXIT
%{if var.pre_install_script != null~}
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
%{endif~}
coder exp sync start ${local.install_script_name}
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
chmod +x ${local.install_path}
${local.install_path} > ${local.install_log_path} 2>&1
EOT
}
resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "Post-Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
coder exp sync start ${local.post_install_script_name}
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
chmod +x ${local.post_install_path}
${local.post_install_path} > ${local.post_install_log_path} 2>&1
EOT
}
resource "coder_script" "start_script" {
agent_id = var.agent_id
display_name = "Start Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.start_script_name}' EXIT
%{if var.post_install_script != null~}
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
%{else~}
coder exp sync want ${local.start_script_name} ${local.install_script_name}
%{endif~}
coder exp sync start ${local.start_script_name}
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
chmod +x ${local.start_path}
${local.start_path} > ${local.start_log_path} 2>&1
EOT
}
output "pre_install_script_name" {
description = "The name of the pre-install script for sync."
value = local.pre_install_script_name
}
output "install_script_name" {
description = "The name of the install script for sync."
value = local.install_script_name
}
output "post_install_script_name" {
description = "The name of the post-install script for sync."
value = local.post_install_script_name
}
output "start_script_name" {
description = "The name of the start script for sync."
value = local.start_script_name
}
@@ -0,0 +1,271 @@
# Test for agent-helper module
# Test with all scripts provided
run "test_with_all_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is created when provided
assert {
condition = length(coder_script.pre_install_script) == 1
error_message = "Pre-install script should be created when pre_install_script is provided"
}
assert {
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
error_message = "Pre-install script agent ID should match input"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
error_message = "Pre-install script should have correct display name"
}
assert {
condition = coder_script.pre_install_script[0].run_on_start == true
error_message = "Pre-install script should run on start"
}
# Verify install_script is created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script agent ID should match input"
}
assert {
condition = coder_script.install_script.display_name == "Install Script"
error_message = "Install script should have correct display name"
}
assert {
condition = coder_script.install_script.run_on_start == true
error_message = "Install script should run on start"
}
# Verify post_install_script is created when provided
assert {
condition = length(coder_script.post_install_script) == 1
error_message = "Post-install script should be created when post_install_script is provided"
}
assert {
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
error_message = "Post-install script agent ID should match input"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
error_message = "Post-install script should have correct display name"
}
assert {
condition = coder_script.post_install_script[0].run_on_start == true
error_message = "Post-install script should run on start"
}
# Verify start_script is created
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script agent ID should match input"
}
assert {
condition = coder_script.start_script.display_name == "Start Script"
error_message = "Start script should have correct display name"
}
assert {
condition = coder_script.start_script.run_on_start == true
error_message = "Start script should run on start"
}
# Verify outputs for script names
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be correctly formatted"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be correctly formatted"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with only required scripts (no pre/post install)
run "test_without_optional_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is NOT created when not provided
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when pre_install_script is null"
}
# Verify post_install_script is NOT created when not provided
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when post_install_script is null"
}
# Verify required scripts are still created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script should be created"
}
# Verify outputs
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be generated even when script is not created"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be generated even when script is not created"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with mock data sources
run "test_with_mock_data" {
command = plan
variables {
agent_id = "mock-agent"
agent_name = "mock-agent"
module_dir_name = ".mock-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Mock the data sources for testing
override_data {
target = data.coder_workspace.me
values = {
id = "test-workspace-id"
name = "test-workspace"
owner = "test-owner"
owner_id = "test-owner-id"
template_id = "test-template-id"
template_name = "test-template"
access_url = "https://coder.example.com"
start_count = 1
transition = "start"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
id = "test-owner-id"
email = "test@example.com"
name = "Test User"
session_token = "mock-token"
}
}
override_data {
target = data.coder_task.me
values = {
id = "test-task-id"
}
}
# Verify scripts are created with mocked data
assert {
condition = coder_script.install_script.agent_id == "mock-agent"
error_message = "Install script should use the mocked agent ID"
}
assert {
condition = coder_script.start_script.agent_id == "mock-agent"
error_message = "Start script should use the mocked agent ID"
}
}
# Test script naming with custom agent_name
run "test_script_naming" {
command = plan
variables {
agent_id = "test-agent"
agent_name = "custom-name"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify script names are constructed correctly
# The script should contain references to custom-name-* in the sync commands
assert {
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
error_message = "Install script should use custom agent_name in sync commands"
}
assert {
condition = can(regex("custom-name-start_script", coder_script.start_script.script))
error_message = "Start script should use custom agent_name in sync commands"
}
# Verify outputs use custom agent_name
assert {
condition = output.pre_install_script_name == "custom-name-pre_install_script"
error_message = "Pre-install script name output should use custom agent_name"
}
assert {
condition = output.install_script_name == "custom-name-install_script"
error_message = "Install script name output should use custom agent_name"
}
assert {
condition = output.post_install_script_name == "custom-name-post_install_script"
error_message = "Post-install script name output should use custom agent_name"
}
assert {
condition = output.start_script_name == "custom-name-start_script"
error_message = "Start script name output should use custom agent_name"
}
}
+1 -68
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.4.0"
version = "2.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -62,73 +62,6 @@ module "agentapi" {
}
```
## State Persistence
AgentAPI can save and restore conversation state across workspace restarts.
This is disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
```tf
module "agentapi" {
# ... other config
enable_state_persistence = true
}
```
To override file paths:
```tf
module "agentapi" {
# ... other config
state_file_path = "/custom/path/state.json"
pid_file_path = "/custom/path/agentapi.pid"
}
```
## Boundary (Network Filtering)
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
variable that points to a wrapper script. Agent modules should use this prefix in their
start scripts to run the agent process through boundary.
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
for configuration details.
To enable:
```tf
module "agentapi" {
# ... other config
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
# Optional: install boundary binary instead of using coder subcommand
# use_boundary_directly        = true
# boundary_version              = "0.6.0"
# compile_boundary_from_source  = false
}
```
### Contract for agent modules
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
as an environment variable pointing to a wrapper script. Agent module start scripts
should check for this variable and use it to prefix the agent command:
```bash
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
else
agentapi server -- my-agent "${ARGS[@]}" &
fi
```
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -1,108 +0,0 @@
mock_provider "coder" {}
variables {
agent_id = "test-agent"
web_app_icon = "/icon/test.svg"
web_app_display_name = "Test"
web_app_slug = "test"
cli_app_display_name = "Test CLI"
cli_app_slug = "test-cli"
start_script = "echo test"
module_dir_name = ".test-module"
}
run "default_values" {
command = plan
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should default to false"
}
assert {
condition = var.state_file_path == ""
error_message = "state_file_path should default to empty string"
}
assert {
condition = var.pid_file_path == ""
error_message = "pid_file_path should default to empty string"
}
# Verify start script contains state persistence ARG_ vars.
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
assert {
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_STATE_FILE_PATH"
}
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
error_message = "start script should contain ARG_PID_FILE_PATH"
}
# Verify shutdown script contains PID-related ARG_ vars.
assert {
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
}
assert {
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
}
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
}
}
run "state_persistence_disabled" {
command = plan
variables {
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false"
}
# Even when disabled, the ARG_ vars should still be in the script
# (the shell script handles the conditional logic).
assert {
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
}
}
run "custom_paths" {
command = plan
variables {
state_file_path = "/custom/state.json"
pid_file_path = "/custom/agentapi.pid"
}
assert {
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
error_message = "start script should contain custom state_file_path"
}
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
error_message = "start script should contain custom pid_file_path"
}
# Verify custom paths also appear in shutdown script.
assert {
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
error_message = "shutdown script should contain custom pid_file_path"
}
}
+2 -310
View File
@@ -258,76 +258,11 @@ describe("agentapi", async () => {
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
test("state-persistence-disabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "false",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
// PID file should always be exported
expect(mockLog).toContain("AGENTAPI_PID_FILE:");
// State vars should NOT be present when disabled
expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:");
expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:");
expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:");
});
test("state-persistence-custom-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
state_file_path: "/home/coder/custom/state.json",
pid_file_path: "/home/coder/custom/agentapi.pid",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
"AGENTAPI_STATE_FILE: /home/coder/custom/state.json",
);
expect(mockLog).toContain(
"AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid",
);
});
test("state-persistence-default-paths", async () => {
const { id } = await setup({
moduleVariables: {
enable_state_persistence: "true",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain(
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
);
expect(mockLog).toContain(
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
);
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
});
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
pidFilePath: string = "",
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
@@ -350,11 +285,10 @@ describe("agentapi", async () => {
content: coderMock,
});
const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : "";
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
@@ -369,25 +303,12 @@ describe("agentapi", async () => {
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
pidFilePath: string = "",
enableStatePersistence: string = "false",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
@@ -397,7 +318,7 @@ describe("agentapi", async () => {
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
@@ -413,7 +334,6 @@ describe("agentapi", async () => {
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
expect(result.stdout).not.toContain("Log snapshot capture failed");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
@@ -489,233 +409,5 @@ describe("agentapi", async () => {
"Log snapshot endpoint not supported by this Coder version",
);
});
test("sends SIGUSR1 before shutdown", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received");
expect(sigusr1Log).toContain("SIGUSR1 received");
});
test("handles missing PID file gracefully", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
// Pass a non-existent PID file path with persistence enabled to
// exercise the SIGUSR1 path with a missing PID.
const result = await runShutdownScript(
id,
"test-task",
"/tmp/nonexistent.pid",
"true",
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Shutdown complete");
});
test("sends SIGTERM even when snapshot fails", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
// HTTP 500 will cause snapshot to fail
await setupMocks(id, "normal", 500, pidFile);
const result = await runShutdownScript(id, "test-task", pidFile, "true");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot capture failed, continuing shutdown",
);
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("resolves default PID path from MODULE_DIR_NAME", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
// Start mock with PID file at the module_dir_name default location.
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
await setupMocks(id, "normal", 204, defaultPidPath);
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
const libScript = await loadTestFile(
import.meta.dir,
"../scripts/lib.sh",
);
await writeExecutable({
containerId: id,
filePath: "/tmp/agentapi-lib.sh",
content: libScript,
});
await writeExecutable({
containerId: id,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
const result = await execContainer(id, [
"bash",
"-c",
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI");
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
test("skips SIGUSR1 when no PID file available", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 204);
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
const result = await runShutdownScript(id, "test-task", "", "false");
expect(result.exitCode).toBe(0);
// Should not send SIGUSR1 or SIGTERM (no PID to signal).
expect(result.stdout).not.toContain("Sending SIGUSR1");
expect(result.stdout).not.toContain("Sending SIGTERM");
expect(result.stdout).toContain("Shutdown complete");
});
test("skips SIGUSR1 when state persistence disabled", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const pidFile = "/tmp/agentapi-test.pid";
await setupMocks(id, "normal", 204, pidFile);
// PID file exists but state persistence is disabled.
const result = await runShutdownScript(id, "test-task", pidFile, "false");
expect(result.exitCode).toBe(0);
// Should NOT send SIGUSR1 (persistence disabled).
expect(result.stdout).not.toContain("Sending SIGUSR1");
// Should still send SIGTERM (graceful shutdown always happens).
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
});
describe("boundary", async () => {
test("boundary-disabled-by-default", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Config file should NOT exist when boundary is disabled
const configCheck = await execContainer(id, [
"bash",
"-c",
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
]);
expect(configCheck.stdout.trim()).toBe("missing");
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config to the path before running the module
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.example.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify the config file exists at the specified path
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
expect(config).toContain("jail_type: landjail");
expect(config).toContain("proxy_port: 8087");
expect(config).toContain("domain=api.example.com");
// AGENTAPI_BOUNDARY_PREFIX should be exported
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
// E2E: start script should have used the wrapper
const startLog = await readFileContainer(
id,
"/home/coder/test-agentapi-start.log",
);
expect(startLog).toContain("Starting with boundary:");
});
test("boundary-enabled-no-coder-binary", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
EOF`,
]);
// Remove coder binary to simulate it not being available
await execContainer(
id,
[
"bash",
"-c",
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
],
["--user", "root"],
);
const resp = await execModuleScript(id);
// Script should fail because coder binary is required
expect(resp.exitCode).not.toBe(0);
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
expect(scriptLog).toContain("Boundary cannot be enabled");
});
});
});
+1 -85
View File
@@ -53,12 +53,6 @@ variable "folder" {
default = "/home/coder"
}
variable "web_app" {
type = bool
description = "Whether to create the web workspace app. This is automatically enabled when using Coder Tasks, regardless of this setting."
default = true
}
variable "cli_app" {
type = bool
description = "Whether to create the CLI workspace app."
@@ -170,67 +164,8 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files."
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
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 = ""
}
resource "coder_env" "boundary_config" {
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
agent_id = var.agent_id
name = "BOUNDARY_CONFIG"
value = var.boundary_config_path
}
locals {
# If this is a Task, always create the web app regardless of var.web_app
# since coder_ai_task requires the app to function.
is_task = try(data.coder_task.me.enabled, false)
web_app = var.web_app || local.is_task
# we always trim the slash for consistency
workdir = trimsuffix(var.folder, "/")
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
@@ -247,8 +182,6 @@ locals {
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
boundary_script = file("${path.module}/scripts/boundary.sh")
}
resource "coder_script" "agentapi" {
@@ -262,10 +195,6 @@ resource "coder_script" "agentapi" {
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /tmp/main.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
chmod +x /tmp/agentapi-boundary.sh
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
@@ -280,13 +209,6 @@ resource "coder_script" "agentapi" {
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_STATE_FILE_PATH='${var.state_file_path}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/main.sh
EOT
run_on_start = true
@@ -303,21 +225,15 @@ resource "coder_script" "agentapi_shutdown" {
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
/tmp/agentapi-shutdown.sh
EOT
}
resource "coder_app" "agentapi_web" {
count = local.web_app ? 1 : 0
slug = var.web_app_slug
display_name = var.web_app_display_name
agent_id = var.agent_id
@@ -354,5 +270,5 @@ resource "coder_app" "agentapi_cli" {
}
output "task_app_id" {
value = local.web_app ? coder_app.agentapi_web[0].id : ""
value = coder_app.agentapi_web.id
}
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save,
# captures the last 10 messages as a log snapshot posted to the Coder instance,
# then sends SIGTERM for graceful termination.
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
@@ -11,13 +11,6 @@ set -euo pipefail
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
# Source shared utilities (written by the coder_script wrapper).
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
@@ -27,7 +20,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
@@ -145,45 +138,44 @@ post_task_log_snapshot() {
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
return 0
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
return 1
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
return 1
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
return 1
exit 1
fi
# Not local, must be visible to the EXIT trap after the function returns.
tmpdir=$(mktemp -d)
trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
return 1
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
return 0
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
@@ -191,7 +183,7 @@ capture_task_log_snapshot() {
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
return 1
exit 1
fi
local final_size final_count
@@ -201,60 +193,19 @@ capture_task_log_snapshot() {
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
return 1
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
local agentapi_pid=
if [[ -n $PID_FILE_PATH ]]; then
agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "")
fi
# State persistence is only enabled when the binary supports it (>= v0.12.0).
# The default SIGUSR1 disposition on Linux is terminate, so sending it to an
# older binary would kill the process.
local state_persistence=0
if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then
state_persistence=1
fi
# Trigger state save via SIGUSR1 (saves without exiting).
if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state"
kill -USR1 "$agentapi_pid" || true
# Allow time for state save to complete before proceeding.
sleep 1
fi
# Capture log snapshot for task history.
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
# Subshell scopes the EXIT trap (tmpdir cleanup) inside
# capture_task_log_snapshot and preserves set -e, which
# || would otherwise disable for the function body.
(capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown"
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
# Graceful termination.
if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then
log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)"
kill -TERM "$agentapi_pid" 2> /dev/null || true
# Wait for process to exit to guarantee a clean shutdown.
local elapsed=0
while kill -0 "$agentapi_pid" 2> /dev/null; do
sleep 1
((elapsed++)) || true
if ((elapsed % 5 == 0)); then
log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s"
fi
done
fi
log "Shutdown complete"
}
@@ -1,95 +0,0 @@
#!/bin/bash
# boundary.sh - Boundary installation and setup for agentapi module.
# Sourced by main.sh when ENABLE_BOUNDARY=true.
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
validate_boundary_subcommand() {
if command_exists coder; then
if coder boundary --help > /dev/null 2>&1; then
return 0
else
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
exit 1
fi
else
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
exit 1
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [ -d boundary ]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Set up boundary: install, write config, create wrapper script.
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
setup_boundary() {
local module_path="$1"
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Determine which boundary command to use and create wrapper script
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary -- "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$module_path/coder-no-caps"
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
exit 1
fi
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
WRAPPER_EOF
fi
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
}
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Shared utility functions for agentapi module scripts.
# version_at_least checks if an actual version meets a minimum requirement.
# Non-semver strings (e.g. "latest", custom builds) always pass.
# Usage: version_at_least <minimum> <actual>
# version_at_least v0.12.0 v0.10.0 # returns 1 (false)
# version_at_least v0.12.0 v0.12.0 # returns 0 (true)
# version_at_least v0.12.0 latest # returns 0 (true)
version_at_least() {
local min="${1#v}"
local actual="${2#v}"
# Non-semver versions pass through (e.g. "latest", custom builds).
if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
return 0
fi
local act_major="${BASH_REMATCH[1]}"
local act_minor="${BASH_REMATCH[2]}"
local act_patch="${BASH_REMATCH[3]}"
[[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0
local min_major="${BASH_REMATCH[1]}"
local min_minor="${BASH_REMATCH[2]}"
local min_patch="${BASH_REMATCH[3]}"
# Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero.
if ((act_major != min_major)); then
((act_major > min_major))
return
fi
if ((act_minor != min_minor)); then
((act_minor > min_minor))
return
fi
((act_patch >= min_patch))
}
# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8").
# Returns empty string if the binary is missing or doesn't support --version.
agentapi_version() {
agentapi --version 2> /dev/null | awk '{print $NF}'
}
@@ -16,18 +16,8 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
set +o nounset
# shellcheck source=lib.sh
source /tmp/agentapi-lib.sh
command_exists() {
command -v "$1" > /dev/null 2>&1
}
@@ -113,30 +103,8 @@ export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
# Set up boundary if enabled
export AGENTAPI_BOUNDARY_PREFIX=""
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
# shellcheck source=boundary.sh
source /tmp/agentapi-boundary.sh
setup_boundary "$module_path"
fi
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
actual_version=$(agentapi_version)
if version_at_least 0.12.0 "$actual_version"; then
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
export AGENTAPI_SAVE_STATE="true"
export AGENTAPI_LOAD_STATE="true"
else
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
fi
fi
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
@@ -3,26 +3,8 @@
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 3284;
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
// Handle SIGUSR1 (state save signal from shutdown script).
process.on("SIGUSR1", () => {
fs.writeFileSync(
"/tmp/sigusr1-received",
`SIGUSR1 received at ${Date.now()}\n`,
);
});
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
@@ -6,50 +6,12 @@ const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284;
if (args.includes("--version")) {
console.log("agentapi version 99.99.99");
process.exit(0);
}
console.log(`starting server on port ${port}`);
fs.writeFileSync(
"/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
);
// Log state persistence env vars.
for (const v of [
"AGENTAPI_STATE_FILE",
"AGENTAPI_PID_FILE",
"AGENTAPI_SAVE_STATE",
"AGENTAPI_LOAD_STATE",
]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Log boundary env vars.
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
const path = require("path");
fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), {
recursive: true,
});
fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid));
}
http
.createServer(function (_request, response) {
response.writeHead(200);
+3 -13
View File
@@ -17,16 +17,6 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
export AGENTAPI_CHAT_BASE_PATH
fi
# Use boundary wrapper if configured by agentapi module.
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
# and points to a wrapper script that runs the command through coder boundary.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
> "$log_file_path" 2>&1
else
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
@@ -1,89 +0,0 @@
---
display_name: AI Bridge Proxy
description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy.
icon: ../../../../.icons/coder.svg
verified: true
tags: [helper, aibridge]
---
# AI Bridge Proxy
This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy).
It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
## How it works
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking.
Any process with the proxy environment variables set will route **all** its traffic through the proxy.
This module **does not** set proxy environment variables globally on the workspace.
Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy.
> [!WARNING]
> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error.
> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details.
## Startup Coordination
When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)),
the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting.
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment:
```hcl
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_AGENT_SOCKET_SERVER_ENABLED=true",
]
```
> [!NOTE]
> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30.
> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time.
## Examples
### Custom certificate path
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem"
}
```
### Proxy with custom port
For deployments where the proxy is accessed directly on a configured port.
See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "http://internal-proxy:8888"
}
```
@@ -1,254 +0,0 @@
import { serve } from "bun";
import {
afterEach,
beforeAll,
describe,
expect,
it,
setDefaultTimeout,
} from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
const FAKE_CERT =
"-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n";
// Runs terraform apply to render the setup script, then starts a Docker
// container where we can execute it against a mock server.
const setupContainer = async (vars: Record<string, string> = {}) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
...vars,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("lorello/alpine-bash");
registerCleanup(async () => {
await removeContainer(id);
});
return { id, instance };
};
// Starts a mock HTTP server that simulates the Coder API certificate endpoint.
// Returns the server and its base URL.
const setupServer = (handler: (req: Request) => Response) => {
const server = serve({
fetch: handler,
port: 0,
});
registerCleanup(async () => {
server.stop();
});
return {
server,
// Base URL without trailing slash
url: server.url.toString().slice(0, -1),
};
};
setDefaultTimeout(30 * 1000);
describe("aibridge-proxy", () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
// Verify that agent_id and proxy_url are required.
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
it("downloads the CA certificate successfully", async () => {
let receivedToken = "";
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
receivedToken = req.headers.get("Coder-Session-Token") || "";
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
// Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server.
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=test-session-token-123",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem",
);
// Verify the cert was written to the default path.
const certContent = await execContainer(id, [
"cat",
"/tmp/aibridge-proxy/ca-cert.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
// Verify the session token was sent in the request header.
expect(receivedToken).toBe("test-session-token-123");
});
it("fails when the server is unreachable", async () => {
const { id, instance } = await setupContainer();
// Port 9999 has nothing listening, so curl will fail to connect.
const exec = await execContainer(id, [
"env",
"ACCESS_URL=http://localhost:9999",
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: could not connect to",
);
});
it("fails when the server returns a non-200 status", async () => {
const { url } = setupServer(() => {
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: unexpected response",
);
});
it("fails when the server returns an empty response", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response("", { status: 200 });
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: downloaded certificate is empty.",
);
});
it("saves the certificate to a custom path", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
// Pass a custom cert_path to terraform apply so the script uses it.
const { id, instance } = await setupContainer({
cert_path: "/tmp/custom/certs/proxy-ca.pem",
});
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem",
);
const certContent = await execContainer(id, [
"cat",
"/tmp/custom/certs/proxy-ca.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
});
it("does not create global proxy env vars via coder_env", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
// Proxy env vars should NOT be set globally via coder_env.
// They are intended to be scoped to specific tool processes.
const proxyEnvVarNames = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
];
const proxyEnvVars = state.resources.filter(
(r) =>
r.type === "coder_env" &&
r.instances.some((i) =>
proxyEnvVarNames.includes(i.attributes.name as string),
),
);
expect(proxyEnvVars.length).toBe(0);
});
});
@@ -1,81 +0,0 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "proxy_url" {
type = string
description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)."
validation {
condition = can(regex("^https?://", var.proxy_url))
error_message = "proxy_url must start with http:// or https://."
}
}
variable "cert_path" {
type = string
description = "Absolute path where the AI Bridge Proxy CA certificate will be saved."
default = "/tmp/aibridge-proxy/ca-cert.pem"
validation {
condition = startswith(var.cert_path, "/")
error_message = "cert_path must be an absolute path."
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
# Build the proxy URL with Coder authentication embedded.
# AI Bridge Proxy expects the Coder session token as the password
# in basic auth: http://coder:<token>@host:port
proxy_auth_url = replace(
var.proxy_url,
"://",
"://coder:${data.coder_workspace_owner.me.session_token}@"
)
}
# These outputs are intended to be consumed by tool-specific modules,
# to set proxy environment variables scoped to their process, rather than globally.
output "proxy_auth_url" {
description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:<token>@host:port)."
value = local.proxy_auth_url
sensitive = true
}
output "cert_path" {
description = "Path to the downloaded AI Bridge Proxy CA certificate."
value = var.cert_path
}
# Downloads the CA certificate from the Coder deployment.
# This runs on workspace start but does not block login, if the script
# fails, the workspace remains usable and the error is visible in the build logs.
# Tools that depend on the proxy will fail until the certificate is available.
resource "coder_script" "aibridge_proxy_setup" {
agent_id = var.agent_id
display_name = "AI Bridge Proxy Setup"
icon = "/icon/coder.svg"
run_on_start = true
start_blocks_login = false
script = templatefile("${path.module}/scripts/setup.sh", {
CERT_PATH = var.cert_path,
ACCESS_URL = data.coder_workspace.me.access_url,
SESSION_TOKEN = data.coder_workspace_owner.me.session_token,
})
}
@@ -1,210 +0,0 @@
run "test_aibridge_proxy_basic" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = var.agent_id == "test-agent-id"
error_message = "Agent ID should match the input variable"
}
assert {
condition = var.proxy_url == "https://aiproxy.example.com"
error_message = "Proxy URL should match the input variable"
}
assert {
condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem"
}
}
run "test_aibridge_proxy_empty_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = ""
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_invalid_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "aiproxy.example.com"
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_url_formats" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should be a valid URL with scheme"
}
}
run "test_aibridge_proxy_https_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com:8443"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTPS with custom port"
}
}
run "test_aibridge_proxy_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTP with custom port"
}
}
run "test_aibridge_proxy_empty_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = ""
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_relative_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "relative/path/ca-cert.pem"
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_custom_cert_path" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/ca-cert.pem"
}
assert {
condition = var.cert_path == "/home/coder/.certs/ca-cert.pem"
error_message = "cert_path should match the input variable"
}
}
run "test_aibridge_proxy_script" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = coder_script.aibridge_proxy_setup.run_on_start == true
error_message = "Script should run on start"
}
assert {
condition = coder_script.aibridge_proxy_setup.start_blocks_login == false
error_message = "Script should not block login"
}
assert {
condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup"
error_message = "Script display name should be 'AI Bridge Proxy Setup'"
}
}
run "test_aibridge_proxy_auth_url_https" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com"
error_message = "proxy_auth_url should contain the mocked session token"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
run "test_aibridge_proxy_auth_url_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888"
error_message = "proxy_auth_url should preserve the port"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
if [ -z "$CERT_PATH" ]; then
CERT_PATH="${CERT_PATH}"
fi
if [ -z "$ACCESS_URL" ]; then
ACCESS_URL="${ACCESS_URL}"
fi
if [ -z "$SESSION_TOKEN" ]; then
SESSION_TOKEN="${SESSION_TOKEN}"
fi
set -euo pipefail
# Signal startup coordination.
# The trap ensures 'complete' is always called (even on failure) so dependent
# scripts unblock promptly and can check for the certificate themselves.
if command -v coder > /dev/null 2>&1; then
coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true
trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ACCESS_URL" ]; then
echo "Error: Coder access URL is not set."
exit 1
fi
if [ -z "$SESSION_TOKEN" ]; then
echo "Error: Coder session token is not set."
exit 1
fi
if ! command -v curl > /dev/null; then
echo "Error: curl is not installed."
exit 1
fi
echo "--------------------------------"
echo "AI Bridge Proxy Setup"
printf "Certificate path: %s\n" "$CERT_PATH"
printf "Access URL: %s\n" "$ACCESS_URL"
echo "--------------------------------"
CERT_DIR=$(dirname "$CERT_PATH")
mkdir -p "$CERT_DIR"
CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem"
echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..."
# Download the certificate with a 5s connection timeout and 10s total timeout
# to avoid the script hanging indefinitely.
if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \
--connect-timeout 5 \
--max-time 10 \
-H "Coder-Session-Token: $SESSION_TOKEN" \
"$CERT_URL"); then
echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ "$HTTP_STATUS" -ne 200 ]; then
echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ ! -s "$CERT_PATH" ]; then
echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty."
rm -f "$CERT_PATH"
exit 1
fi
echo "AI Bridge Proxy CA certificate saved to $CERT_PATH"
echo "✅ AI Bridge Proxy setup complete."
+127 -127
View File
@@ -1,121 +1,139 @@
---
display_name: Claude Code
description: Install and configure the Claude Code CLI in your workspace.
description: Run the Claude Code agent in your workspace.
icon: ../../../../.icons/claude.svg
verified: true
tags: [agent, claude-code, ai, anthropic, ai-gateway]
tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
---
# Claude Code
Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) CLI in your workspace. Starting Claude is left to the caller (template command, IDE launcher, or a custom `coder_script`).
Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
}
```
> [!WARNING]
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). We plan to add those back in a follow-up. Keep using v4.x.x if you depend on them. See [#861](https://github.com/coder/registry/pull/861) for the full migration guide.
> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
> [!NOTE]
> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
## Prerequisites
Provide exactly one authentication method:
- An **Anthropic API key** or a _Claude Session Token_ is required for tasks.
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
- **Anthropic API key**: get one from the [Anthropic Console](https://console.anthropic.com/dashboard) and pass it as `anthropic_api_key`.
- **Claude.ai OAuth token** (Pro, Max, or Enterprise accounts): generate one by running `claude setup-token` locally and pass it as `claude_code_oauth_token`.
- **Coder AI Gateway** (Coder Premium, Coder >= 2.30.0): set `enable_ai_gateway = true`. The module authenticates against the gateway using the workspace owner's session token. Do not combine with `anthropic_api_key` or `claude_code_oauth_token`.
### Session Resumption Behavior
## workdir
`workdir` is optional. When set, the module pre-creates the directory if it is missing and pre-accepts the Claude Code trust/onboarding prompt for it in `~/.claude.json`. Leave `workdir` unset if you only want the module to install the CLI and configure authentication; users can still open any project interactively and accept the trust dialog per project.
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`
## Examples
### Standalone mode with a launcher app
### Usage with Agent Boundaries
Authenticate Claude directly against Anthropic's API and add a `coder_app` that users can click from the workspace dashboard to open an interactive Claude session.
This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access.
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
```tf
locals {
claude_workdir = "/home/coder/project"
}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
resource "coder_app" "claude" {
agent_id = coder_agent.main.id
slug = "claude"
display_name = "Claude Code"
icon = "/icon/claude.svg"
open_in = "slim-window"
command = <<-EOT
#!/bin/bash
set -e
cd ${local.claude_workdir}
claude
EOT
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
}
```
> [!NOTE]
> `coder_app.command` runs when the user clicks the app tile. Combine with `anthropic_api_key`, `claude_code_oauth_token`, or `enable_ai_gateway = true` on the module to pre-authenticate the CLI.
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
### Usage with AI Gateway
### Usage with AI Bridge
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0.
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0.
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
}
```
When `enable_ai_gateway = true`, the module sets:
When `enable_aibridge = true`, the module automatically sets:
- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic`
- `ANTHROPIC_AUTH_TOKEN` to the workspace owner's Coder session token
- `CLAUDE_API_KEY` to the workspace owner's session token
Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic.
This allows Claude Code to route API requests through Coder's AI Bridge instead of directly to Anthropic's API.
Template build will fail if either `claude_api_key` or `claude_code_oauth_token` is provided alongside `enable_aibridge = true`.
> [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
### Usage with Tasks
This example shows how to configure Claude Code with Coder tasks.
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.claude-code.task_app_id
}
data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
### Advanced Configuration
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
This example shows additional configuration options for version pinning, custom models, and MCP servers.
> [!NOTE]
> The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located.
> [!WARNING]
> **Deprecation Notice**: The npm installation method (`install_via_npm = true`) will be deprecated and removed in the next major release. Please use the default binary installation method instead.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
claude_api_key = "xxxx-xxxxx-xxxx"
# OR
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
claude_code_version = "2.0.62" # Pin to a specific Claude CLI version.
claude_code_version = "2.0.62" # Pin to a specific version
claude_binary_path = "/opt/claude/bin" # Path to pre-installed Claude binary
agentapi_version = "0.11.4"
# Skip the module's installer and point at a pre-installed Claude binary.
# claude_binary_path can only be customized when install_claude_code is false.
install_claude_code = false
claude_binary_path = "/opt/claude/bin"
model = "sonnet"
model = "sonnet"
permission_mode = "plan"
mcp = <<-EOF
{
@@ -135,12 +153,6 @@ module "claude-code" {
}
```
> [!NOTE]
> Swap `anthropic_api_key` for `claude_code_oauth_token = "xxxxx-xxxx-xxxx"` to authenticate via a Claude.ai OAuth token instead. Pass exactly one.
> [!NOTE]
> Servers configured through `mcp` or `mcp_config_remote_path` are added at Claude Code's [user scope](https://docs.claude.com/en/docs/claude-code/mcp#scope), making them available across every project the workspace owner opens. For project-local MCP servers, commit a `.mcp.json` to the project repository instead.
> [!NOTE]
> Remote URLs should return a JSON body in the following format:
>
@@ -155,37 +167,41 @@ module "claude-code" {
> }
> ```
>
> The `Content-Type` header doesn't matter, both `text/plain` and `application/json` work fine.
> The `Content-Type` header doesn't matterboth `text/plain` and `application/json` work fine.
### Serialize a downstream `coder_script` after the install pipeline
### Standalone Mode
The module exposes the `coder exp sync` name of each script it creates via the `scripts` output: an ordered list (`pre_install`, `install`, `post_install`) of names for scripts this module actually creates. Scripts that were not configured are absent from the list.
Downstream `coder_script` resources can wait for this module's install pipeline to finish using `coder exp sync want <self> <each name>`:
Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
claude_code_version = "2.0.62"
report_tasks = false
}
```
### Usage with Claude Code Subscription
```tf
variable "claude_code_oauth_token" {
type = string
description = "Generate one using `claude setup-token` command"
sensitive = true
value = "xxxx-xxx-xxxx"
}
resource "coder_script" "post_claude" {
agent_id = coder_agent.main.id
display_name = "Run after Claude Code install"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -euo pipefail
trap 'coder exp sync complete post-claude' EXIT
coder exp sync want post-claude ${join(" ", module.claude-code.scripts)}
coder exp sync start post-claude
# Your work here runs after claude-code finishes installing.
claude --version
EOT
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
}
```
@@ -216,12 +232,14 @@ variable "aws_access_key_id" {
type = string
description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'."
sensitive = true
value = "xxxx-xxx-xxxx"
}
variable "aws_secret_access_key" {
type = string
description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console."
sensitive = true
value = "xxxx-xxx-xxxx"
}
resource "coder_env" "aws_access_key_id" {
@@ -242,6 +260,7 @@ variable "aws_bearer_token_bedrock" {
type = string
description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key."
sensitive = true
value = "xxxx-xxx-xxxx"
}
resource "coder_env" "bedrock_api_key" {
@@ -252,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -309,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -341,47 +360,28 @@ module "claude-code" {
> [!NOTE]
> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai).
### Telemetry export (OpenTelemetry)
Claude Code can emit OpenTelemetry metrics and events covering token usage, tool calls, session lifecycle, and errors (see the [monitoring docs](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage)). Set `telemetry.enabled = true` and point `otlp_endpoint` at your OTLP collector.
The module automatically tags every span and metric with `coder.workspace_id`, `coder.workspace_name`, `coder.workspace_owner`, and `coder.template_name` via `OTEL_RESOURCE_ATTRIBUTES`, so Claude Code telemetry can be joined directly against Coder's [audit logs](https://coder.com/docs/admin/security/audit-logs) and `exectrace` records on `workspace_id`.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
telemetry = {
enabled = true
otlp_endpoint = "http://otel-collector.observability:4317"
otlp_protocol = "grpc"
otlp_headers = {
authorization = "Bearer ${var.otel_token}"
}
resource_attributes = {
"service.name" = "claude-code"
}
}
}
```
## Troubleshooting
If you encounter any issues, check the log files in the `~/.coder-modules/coder/claude-code/logs` directory within your workspace for detailed information.
If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
```bash
# Installation logs
cat ~/.coder-modules/coder/claude-code/logs/install.log
cat ~/.claude-module/install.log
# Startup logs
cat ~/.claude-module/agentapi-start.log
# Pre/post install script logs
cat ~/.coder-modules/coder/claude-code/logs/pre_install.log
cat ~/.coder-modules/coder/claude-code/logs/post_install.log
cat ~/.claude-module/pre_install.log
cat ~/.claude-module/post_install.log
```
> [!NOTE]
> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`.
> The `workdir` variable is required and specifies the directory where Claude Code will run.
## References
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
+355 -316
View File
@@ -6,72 +6,15 @@ import {
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
execContainer,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
TerraformState,
} from "~test";
import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util";
import path from "path";
// coder-utils orchestrates this module's scripts and can produce multiple
// coder_script resources (pre_install, install, post_install). The shared
// `setup` helper in ../agentapi/test-util.ts assumes a single coder_script
// via findResourceInstance, so we define a local setup helper that collects
// every coder_script in run order.
interface ModuleScripts {
pre_install?: string;
install: string;
post_install?: string;
}
// Script display_names produced by coder-utils (Claude Code prefix + suffix).
// Order matters: scripts run sequentially in this order at agent startup.
const SCRIPT_SUFFIXES = [
"Pre-Install Script",
"Install Script",
"Post-Install Script",
] as const;
const collectScripts = (state: TerraformState): ModuleScripts => {
const byDisplayName: Record<string, string> = {};
for (const resource of state.resources) {
if (resource.type !== "coder_script") continue;
for (const instance of resource.instances) {
const attrs = instance.attributes as Record<string, unknown>;
const displayName = attrs.display_name as string | undefined;
const script = attrs.script as string | undefined;
if (displayName && script) {
byDisplayName[displayName] = script;
}
}
}
const scripts: Partial<ModuleScripts> = {};
for (const suffix of SCRIPT_SUFFIXES) {
const key = `Claude Code: ${suffix}`;
if (!(key in byDisplayName)) continue;
switch (suffix) {
case "Pre-Install Script":
scripts.pre_install = byDisplayName[key];
break;
case "Install Script":
scripts.install = byDisplayName[key];
break;
case "Post-Install Script":
scripts.post_install = byDisplayName[key];
break;
}
}
if (!scripts.install) {
throw new Error("install script not found in terraform state");
}
return scripts as ModuleScripts;
};
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../agentapi/test-util";
import dedent from "dedent";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
@@ -90,96 +33,37 @@ afterEach(async () => {
});
interface SetupProps {
skipAgentAPIMock?: boolean;
skipClaudeMock?: boolean;
moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
}
const setup = async (
props?: SetupProps,
): Promise<{
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
const projectDir = "/home/coder/project";
const moduleDir = path.resolve(import.meta.dir);
const state = await runTerraformApply(moduleDir, {
agent_id: "foo",
workdir: projectDir,
// Default to skipping the real installer; individual tests opt in.
install_claude_code: "false",
...props?.moduleVariables,
});
const scripts = collectScripts(state);
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer("codercom/enterprise-node:latest");
registerCleanup(async () => {
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
console.log(`Not removing container ${id} in debug mode`);
return;
}
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
// succeed without a real control plane.
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/bash\nexit 0\n",
const { id, coderEnvVars } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
install_claude_code: props?.skipClaudeMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
workdir: projectDir,
...props?.moduleVariables,
},
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipClaudeMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/claude",
content: await Bun.file(
path.join(moduleDir, "testdata", "claude-mock.sh"),
).text(),
content: await loadTestFile(import.meta.dir, "claude-mock.sh"),
});
}
return { id, coderEnvVars, scripts };
};
// Runs the coder-utils script pipeline (pre_install, install, post_install) in
// order inside the container. Each script is written to /tmp and executed
// under bash with the test's env vars exported first.
const runScripts = async (
id: string,
scripts: ModuleScripts,
env?: Record<string, string>,
) => {
const entries = env ? Object.entries(env) : [];
const envArgs =
entries.length > 0
? entries
.map(
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
)
.join(" && ") + " && "
: "";
const ordered: [string, string | undefined][] = [
["pre_install", scripts.pre_install],
["install", scripts.install],
["post_install", scripts.post_install],
];
for (const [name, script] of ordered) {
if (!script) continue;
const target = `/tmp/coder-utils-${name}.sh`;
await writeExecutable({
containerId: id,
filePath: target,
content: script,
});
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
if (resp.exitCode !== 0) {
console.log(`script ${name} failed:`);
console.log(resp.stdout);
console.log(resp.stderr);
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
}
}
return { id, coderEnvVars };
};
setDefaultTimeout(60 * 1000);
@@ -190,50 +74,56 @@ describe("claude-code", async () => {
});
test("happy-path", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Skipping Claude Code installation");
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("install-claude-code-version", async () => {
const version = "1.0.40";
const { id, coderEnvVars, scripts } = await setup({
const version_to_install = "1.0.40";
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
claude_code_version: version,
claude_code_version: version_to_install,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain(version);
await execModuleScript(id, coderEnvVars);
const resp = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/install.log",
]);
expect(resp.stdout).toContain(version_to_install);
});
test("anthropic-api-key", async () => {
test("check-latest-claude-code-version-works", async () => {
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
skipAgentAPIMock: true,
moduleVariables: {
install_claude_code: "true",
},
});
await execModuleScript(id, coderEnvVars);
await expectAgentAPIStarted(id);
});
test("claude-api-key", async () => {
const apiKey = "test-api-key-123";
const { coderEnvVars } = await setup({
const { id } = await setup({
moduleVariables: {
anthropic_api_key: apiKey,
claude_api_key: apiKey,
},
});
expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey);
});
await execModuleScript(id);
test("claude-code-oauth-token", async () => {
const token = "test-oauth-token-456";
const { coderEnvVars } = await setup({
moduleVariables: {
claude_code_oauth_token: token,
},
});
expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token);
const envCheck = await execContainer(id, [
"bash",
"-c",
'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"',
]);
expect(envCheck.stdout).toContain("CLAUDE_API_KEY");
});
test("claude-mcp-config", async () => {
@@ -245,67 +135,331 @@ describe("claude-code", async () => {
},
},
});
const { id, coderEnvVars, scripts } = await setup({
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
mcp: mcpConfig,
},
});
await runScripts(id, scripts, coderEnvVars);
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
expect(claudeConfig).toContain("test-cmd");
await execModuleScript(id, coderEnvVars);
const resp = await readFileContainer(id, "/home/coder/.claude.json");
expect(resp).toContain("test-cmd");
});
test("claude-task-prompt", async () => {
const prompt = "This is a task prompt for Claude.";
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(resp.stdout).toContain(prompt);
});
test("claude-permission-mode", async () => {
const mode = "plan";
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
});
test("claude-model", async () => {
const model = "opus";
const { coderEnvVars } = await setup({
moduleVariables: {
model,
model: model,
ai_prompt: "test prompt",
},
});
// Verify ANTHROPIC_MODEL env var is set via coder_env
expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
});
test("claude-continue-resume-task-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
// Create a mock task session file with the hardcoded task session ID
// Note: Claude CLI creates files without "session-" prefix when using --session-id
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'SESSIONEOF'
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
SESSIONEOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("--resume");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).toContain("Resuming task session");
expect(startLog.stdout).toContain("--dangerously-skip-permissions");
});
test("pre-post-install-scripts", async () => {
const { id, scripts } = await setup({
const { id } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
},
});
await runScripts(id, scripts);
await execModuleScript(id);
const preInstallLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/pre_install.log",
"/home/coder/.claude-module/pre_install.log",
);
expect(preInstallLog).toContain("claude-pre-install-script");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/post_install.log",
"/home/coder/.claude-module/post_install.log",
);
expect(postInstallLog).toContain("claude-post-install-script");
});
test("workdir-variable", async () => {
const workdir = "/home/coder/claude-test-folder";
const { id, scripts } = await setup({
const { id } = await setup({
skipClaudeMock: false,
moduleVariables: {
workdir,
},
});
await runScripts(id, scripts);
// install.sh.tftpl echoes ARG_WORKDIR and creates the directory if missing.
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.claude-module/agentapi-start.log",
);
expect(resp).toContain(workdir);
});
test("coder-mcp-config-created", async () => {
const { id } = await setup({
moduleVariables: {
install_claude_code: "false",
},
});
await execModuleScript(id);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
"/home/coder/.claude-module/install.log",
);
expect(installLog).toContain(workdir);
expect(installLog).toContain(
"Configuring Claude Code to report tasks via Coder MCP",
);
});
test("dangerously-skip-permissions", async () => {
const { id } = await setup({
moduleVariables: {
dangerously_skip_permissions: "true",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--dangerously-skip-permissions`);
});
test("subdomain-false", async () => {
const { id } = await setup({
skipAgentAPIMock: true,
moduleVariables: {
subdomain: "false",
post_install_script: dedent`
#!/bin/bash
env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found"
`,
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/post_install.log",
]);
expect(startLog.stdout).toContain(
"ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat",
);
});
test("partial-initialization-detection", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`echo '{"sessionId":"${taskSessionId}"}' > ${sessionDir}/${taskSessionId}.jsonl`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should start new session, not try to resume invalid one
expect(startLog.stdout).toContain("Starting new task session");
expect(startLog.stdout).toContain("--session-id");
});
test("standalone-first-build-no-sessions", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "false",
},
});
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should start fresh, not try to continue
expect(startLog.stdout).toContain("No sessions found");
expect(startLog.stdout).toContain("starting fresh standalone session");
expect(startLog.stdout).not.toContain("--continue");
});
test("standalone-with-sessions-continues", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "false",
},
});
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/generic-123.jsonl << 'EOF'
{"sessionId":"generic-123","message":{"content":"User session"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
EOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should continue existing session
expect(startLog.stdout).toContain("Sessions found");
expect(startLog.stdout).toContain(
"Continuing most recent standalone session",
);
expect(startLog.stdout).toContain("--continue");
});
test("task-mode-ignores-manual-sessions", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
report_tasks: "true",
ai_prompt: "test prompt",
},
});
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
// Create task session (without "session-" prefix, as CLI does)
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'EOF'
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
EOF`,
]);
// Create manual session (newer)
await execContainer(id, [
"bash",
"-c",
`cat > ${sessionDir}/manual-456.jsonl << 'EOF'
{"sessionId":"manual-456","message":{"content":"Manual"},"timestamp":"2020-01-02T10:00:00.000Z"}
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-02T10:00:05.000Z"}
EOF`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
// Should resume task session, not manual session
expect(startLog.stdout).toContain("Resuming task session");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).not.toContain("manual-456");
});
test("mcp-config-remote-path", async () => {
@@ -313,43 +467,43 @@ describe("claude-code", async () => {
const successUrl =
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
const { id, coderEnvVars, scripts } = await setup({
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
},
});
await runScripts(id, scripts, coderEnvVars);
await execModuleScript(id, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
"/home/coder/.claude-module/install.log",
);
// Verify both URLs are attempted.
// Verify both URLs are attempted
expect(installLog).toContain(failingUrl);
expect(installLog).toContain(successUrl);
// First URL should fail gracefully.
// First URL should fail gracefully
expect(installLog).toContain(
`Warning: Failed to fetch MCP configuration from '${failingUrl}'`,
);
// Second URL should succeed.
// Second URL should succeed - no failure warning for it
expect(installLog).not.toContain(
`Warning: Failed to fetch MCP configuration from '${successUrl}'`,
);
// Should contain the MCP server add command from the successful fetch.
// Should contain the MCP server add command from successful fetch
expect(installLog).toContain(
"Added stdio MCP server go-language-server to user config",
);
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to user config",
"Added stdio MCP server go-language-server to local config",
);
// Verify the MCP config was added to .claude.json.
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to local config",
);
// Verify the MCP config was added to claude.json
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
@@ -357,119 +511,4 @@ describe("claude-code", async () => {
expect(claudeConfig).toContain("typescript-language-server");
expect(claudeConfig).toContain("go-language-server");
});
test("standalone-mode-with-api-key", async () => {
const apiKey = "test-api-key-standalone";
const workdir = "/home/coder/project";
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
anthropic_api_key: apiKey,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Configuring Claude Code for standalone mode");
expect(installLog).toContain("Standalone mode configured successfully");
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
});
test("standalone-mode-with-oauth-token", async () => {
const token = "test-oauth-token-standalone";
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
claude_code_oauth_token: token,
},
});
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Standalone mode configured successfully");
expect(installLog).not.toContain("skipping onboarding bypass");
// Onboarding bypass flags must be present. Authentication happens via
// the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env vars, not via
// .claude.json.
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
});
test("standalone-mode-no-auth", async () => {
const { id, coderEnvVars, scripts } = await setup();
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("No authentication configured");
expect(installLog).toContain("skipping onboarding bypass");
// .claude.json should not exist when no auth is configured.
const resp = await execContainer(id, [
"bash",
"-c",
"test -e /home/coder/.claude.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("telemetry-otel", async () => {
const { coderEnvVars } = await setup({
moduleVariables: {
telemetry: JSON.stringify({
enabled: true,
otlp_endpoint: "http://otel-collector:4317",
otlp_protocol: "grpc",
otlp_headers: { authorization: "Bearer test-token" },
resource_attributes: { "service.name": "claude-code" },
}),
},
});
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1");
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe(
"http://otel-collector:4317",
);
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("grpc");
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe(
"authorization=Bearer test-token",
);
const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"];
expect(attrs).toContain("coder.workspace_id=");
expect(attrs).toContain("coder.workspace_name=");
expect(attrs).toContain("coder.workspace_owner=");
expect(attrs).toContain("coder.template_name=");
expect(attrs).toContain("service.name=claude-code");
});
test("telemetry-disabled-by-default", async () => {
const { coderEnvVars } = await setup();
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBeUndefined();
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined();
expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined();
});
});
+279 -117
View File
@@ -18,6 +18,18 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -26,13 +38,36 @@ variable "icon" {
variable "workdir" {
type = string
description = "Optional project directory. When set, the module pre-creates it if missing and pre-accepts the Claude Code trust/onboarding prompt for it in ~/.claude.json."
default = null
description = "The folder to run Claude Code in."
}
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI via AgentAPI"
default = true
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Claude Code"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Claude Code"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Claude Code CLI"
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)."
description = "Custom script to run before installing Claude Code."
default = null
}
@@ -42,6 +77,31 @@ variable "post_install_script" {
default = null
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.8"
}
variable "ai_prompt" {
type = string
description = "Initial task prompt for Claude Code."
default = ""
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = false
}
variable "install_claude_code" {
type = bool
description = "Whether to install Claude Code."
@@ -60,9 +120,9 @@ variable "disable_autoupdater" {
default = false
}
variable "anthropic_api_key" {
variable "claude_api_key" {
type = string
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var."
description = "The API key to use for the Claude Code server."
default = ""
}
@@ -72,25 +132,78 @@ variable "model" {
default = ""
}
variable "resume_session_id" {
type = string
description = "Resume a specific session by ID."
default = ""
}
variable "continue" {
type = bool
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
default = true
}
variable "dangerously_skip_permissions" {
type = bool
description = "Skip the permission prompts. Use with caution. This will be set to true if using Coder Tasks"
default = false
}
variable "permission_mode" {
type = string
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
default = ""
validation {
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
error_message = "interaction_mode must be one of: default, acceptEdits, plan, bypassPermissions."
}
}
variable "mcp" {
type = string
description = "JSON-encoded string of MCP server configurations. When set, servers are added at Claude Code's user scope so they are available across every project the workspace owner opens."
description = "MCP JSON to be added to the claude code local scope"
default = ""
}
variable "mcp_config_remote_path" {
type = list(string)
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON). Servers are added at Claude Code's user scope."
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)"
default = []
}
variable "allowed_tools" {
type = string
description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files."
default = ""
}
variable "disallowed_tools" {
type = string
description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files."
default = ""
}
variable "claude_code_oauth_token" {
type = string
description = "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`."
description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command"
sensitive = true
default = ""
}
variable "system_prompt" {
type = string
description = "The system prompt to use for the Claude Code server."
default = ""
}
variable "claude_md_path" {
type = string
description = "The path to CLAUDE.md."
default = "$HOME/.claude/CLAUDE.md"
}
variable "claude_binary_path" {
type = string
description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location."
@@ -102,55 +215,77 @@ variable "claude_binary_path" {
}
}
variable "enable_ai_gateway" {
variable "install_via_npm" {
type = bool
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
description = "Install Claude Code via npm instead of the official installer. Useful if npm is preferred or the official installer fails."
default = false
}
variable "enable_boundary" {
type = bool
description = "Whether to enable coder boundary for network filtering"
default = false
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script"
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_ai_gateway && length(var.anthropic_api_key) > 0)
error_message = "anthropic_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials."
condition = !(var.enable_aibridge && length(var.claude_api_key) > 0)
error_message = "claude_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
validation {
condition = !(var.enable_ai_gateway && length(var.claude_code_oauth_token) > 0)
error_message = "claude_code_oauth_token cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials."
condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0)
error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
variable "telemetry" {
type = object({
enabled = optional(bool, false)
otlp_endpoint = optional(string, "")
otlp_protocol = optional(string, "http/protobuf")
otlp_headers = optional(map(string), {})
resource_attributes = optional(map(string), {})
})
default = {}
description = "Configure Claude Code OpenTelemetry export. When enabled, sets CLAUDE_CODE_ENABLE_TELEMETRY and the standard OTEL_EXPORTER_OTLP_* environment variables. Coder workspace identifiers (coder.workspace_id, coder.workspace_name, coder.workspace_owner, coder.template_name) are automatically appended to OTEL_RESOURCE_ATTRIBUTES so Claude Code telemetry can be joined with Coder audit and exectrace logs."
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_MD_PATH"
value = var.claude_md_path
}
resource "coder_env" "claude_code_system_prompt" {
agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
value = local.final_system_prompt
}
resource "coder_env" "claude_code_oauth_token" {
count = var.claude_code_oauth_token != "" ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_CODE_OAUTH_TOKEN"
value = var.claude_code_oauth_token
}
resource "coder_env" "anthropic_api_key" {
count = var.anthropic_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_API_KEY"
value = var.anthropic_api_key
}
resource "coder_env" "claude_api_key" {
count = local.claude_api_key != "" ? 1 : 0
# ANTHROPIC_AUTH_TOKEN authenticates the client against Coder's AI Gateway
# using the workspace owner's session token, per the AI Gateway docs.
resource "coder_env" "anthropic_auth_token" {
count = var.enable_ai_gateway ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_AUTH_TOKEN"
value = data.coder_workspace_owner.me.session_token
name = "CLAUDE_API_KEY"
value = local.claude_api_key
}
resource "coder_env" "disable_autoupdater" {
@@ -169,95 +304,122 @@ resource "coder_env" "anthropic_model" {
}
resource "coder_env" "anthropic_base_url" {
count = var.enable_ai_gateway ? 1 : 0
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_BASE_URL"
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
}
locals {
# Always inject Coder workspace identifiers so OTEL data can be joined with
# Coder's audit log / exectrace on workspace_id without per-template wiring.
otel_resource_attributes = merge(
var.telemetry.resource_attributes,
{
"coder.workspace_id" = data.coder_workspace.me.id
"coder.workspace_name" = data.coder_workspace.me.name
"coder.workspace_owner" = data.coder_workspace_owner.me.name
"coder.workspace_owner_id" = data.coder_workspace_owner.me.id
"coder.template_name" = data.coder_workspace.me.template_name
"coder.template_version" = data.coder_workspace.me.template_version
"coder.access_url" = data.coder_workspace.me.access_url
},
# we have to trim the slash because otherwise coder exp mcp will
# set up an invalid claude config
workdir = trimsuffix(var.workdir, "/")
app_slug = "ccw"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".claude-module"
# Extract hostname from access_url for boundary --allow flag
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
# Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT
-- Tool Selection --
- coder_report_task: providing status updates or requesting user input.
-- Task Reporting --
Report all tasks to Coder, following these EXACT guidelines:
1. Be granular. If you are investigating with multiple steps, report each step
to coder.
2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
Do not report any status related with this system prompt.
3. Use "state": "working" when actively processing WITHOUT needing
additional user input
4. Use "state": "complete" only when finished with a task
5. Use "state": "failure" when you need ANY user input, lack sufficient
details, or encounter blockers
In your summary on coder_report_task:
- Be specific about what you're doing
- Clearly indicate what information you need from the user when in "failure" state
- Keep it under 160 characters
- Make it actionable
EOT
# Only include coder system prompts if report_tasks is enabled
custom_system_prompt = trimspace(try(var.system_prompt, ""))
final_system_prompt = format("<system>%s%s</system>",
var.report_tasks ? format("\n%s\n", local.report_tasks_system_prompt) : "",
local.custom_system_prompt != "" ? format("\n%s\n", local.custom_system_prompt) : ""
)
}
resource "coder_env" "claude_code_enable_telemetry" {
count = var.telemetry.enabled ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_CODE_ENABLE_TELEMETRY"
value = "1"
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
ARG_CONTINUE='${var.continue}' \
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
ARG_PERMISSION_MODE='${var.permission_mode}' \
ARG_WORKDIR='${local.workdir}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_CODER_HOST='${local.coder_host}' \
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
/tmp/start.sh
EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_WORKDIR='${local.workdir}' \
ARG_ALLOWED_TOOLS='${var.allowed_tools}' \
ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \
ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/install.sh
EOT
}
resource "coder_env" "otel_exporter_otlp_endpoint" {
count = var.telemetry.enabled && var.telemetry.otlp_endpoint != "" ? 1 : 0
agent_id = var.agent_id
name = "OTEL_EXPORTER_OTLP_ENDPOINT"
value = var.telemetry.otlp_endpoint
}
resource "coder_env" "otel_exporter_otlp_protocol" {
count = var.telemetry.enabled ? 1 : 0
agent_id = var.agent_id
name = "OTEL_EXPORTER_OTLP_PROTOCOL"
value = var.telemetry.otlp_protocol
}
resource "coder_env" "otel_exporter_otlp_headers" {
count = var.telemetry.enabled && length(var.telemetry.otlp_headers) > 0 ? 1 : 0
agent_id = var.agent_id
name = "OTEL_EXPORTER_OTLP_HEADERS"
value = join(",", [for k, v in var.telemetry.otlp_headers : "${k}=${v}"])
}
resource "coder_env" "otel_resource_attributes" {
count = var.telemetry.enabled ? 1 : 0
agent_id = var.agent_id
name = "OTEL_RESOURCE_ATTRIBUTES"
value = join(",", [for k, v in local.otel_resource_attributes : "${k}=${v}"])
}
locals {
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_CLAUDE_CODE_VERSION = var.claude_code_version
ARG_INSTALL_CLAUDE_CODE = tostring(var.install_claude_code)
ARG_CLAUDE_BINARY_PATH = var.claude_binary_path
ARG_WORKDIR = local.workdir
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
})
module_dir_name = ".coder-modules/coder/claude-code"
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
module_directory = "$HOME/${local.module_dir_name}"
display_name_prefix = "Claude Code"
icon = var.icon
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
}
# Pass-through of coder-utils script outputs so upstream modules can serialize
# their coder_script resources behind this module's install pipeline using
# `coder exp sync want <self> <each name>`.
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
output "task_app_id" {
value = module.agentapi.task_app_id
}
+229 -111
View File
@@ -20,20 +20,30 @@ run "test_claude_code_basic" {
condition = var.install_claude_code == true
error_message = "Install claude_code should default to true"
}
assert {
condition = var.install_agentapi == true
error_message = "Install agentapi should default to true"
}
assert {
condition = var.report_tasks == true
error_message = "report_tasks should default to true"
}
}
run "test_claude_code_with_api_key" {
command = plan
variables {
agent_id = "test-agent-456"
workdir = "/home/coder/workspace"
anthropic_api_key = "test-api-key-123"
agent_id = "test-agent-456"
workdir = "/home/coder/workspace"
claude_api_key = "test-api-key-123"
}
assert {
condition = coder_env.anthropic_api_key[0].value == "test-api-key-123"
error_message = "Anthropic API key value should match the input"
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
error_message = "Claude API key value should match the input"
}
}
@@ -41,12 +51,30 @@ run "test_claude_code_with_custom_options" {
command = plan
variables {
agent_id = "test-agent-789"
workdir = "/home/coder/custom"
icon = "/icon/custom.svg"
model = "opus"
install_claude_code = false
claude_code_version = "1.0.0"
agent_id = "test-agent-789"
workdir = "/home/coder/custom"
order = 5
group = "development"
icon = "/icon/custom.svg"
model = "opus"
ai_prompt = "Help me write better code"
permission_mode = "plan"
continue = true
install_claude_code = false
install_agentapi = false
claude_code_version = "1.0.0"
agentapi_version = "v0.6.0"
dangerously_skip_permissions = true
}
assert {
condition = var.order == 5
error_message = "Order variable should be set to 5"
}
assert {
condition = var.group == "development"
error_message = "Group variable should be set to 'development'"
}
assert {
@@ -59,13 +87,38 @@ run "test_claude_code_with_custom_options" {
error_message = "Claude model variable should be set to 'opus'"
}
assert {
condition = var.ai_prompt == "Help me write better code"
error_message = "AI prompt variable should be set correctly"
}
assert {
condition = var.permission_mode == "plan"
error_message = "Permission mode should be set to 'plan'"
}
assert {
condition = var.continue == true
error_message = "Continue should be set to true"
}
assert {
condition = var.claude_code_version == "1.0.0"
error_message = "Claude Code version should be set to '1.0.0'"
}
assert {
condition = var.agentapi_version == "v0.6.0"
error_message = "AgentAPI version should be set to 'v0.6.0'"
}
assert {
condition = var.dangerously_skip_permissions == true
error_message = "dangerously_skip_permissions should be set to true"
}
}
run "test_claude_code_with_mcp" {
run "test_claude_code_with_mcp_and_tools" {
command = plan
variables {
@@ -79,12 +132,24 @@ run "test_claude_code_with_mcp" {
}
}
})
allowed_tools = "bash,python"
disallowed_tools = "rm"
}
assert {
condition = var.mcp != ""
error_message = "MCP configuration should be provided"
}
assert {
condition = var.allowed_tools == "bash,python"
error_message = "Allowed tools should be set"
}
assert {
condition = var.disallowed_tools == "rm"
error_message = "Disallowed tools should be set"
}
}
run "test_claude_code_with_scripts" {
@@ -108,13 +173,129 @@ run "test_claude_code_with_scripts" {
}
}
run "test_ai_gateway_enabled" {
run "test_claude_code_permission_mode_validation" {
command = plan
variables {
agent_id = "test-agent-ai-gateway"
workdir = "/home/coder/ai-gateway"
enable_ai_gateway = true
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
permission_mode = "acceptEdits"
}
assert {
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
error_message = "Permission mode should be one of the valid options"
}
}
run "test_claude_code_with_boundary" {
command = plan
variables {
agent_id = "test-agent-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = true
}
assert {
condition = var.enable_boundary == true
error_message = "Boundary should be enabled"
}
assert {
condition = local.coder_host != ""
error_message = "Coder host should be extracted from access URL"
}
}
run "test_claude_code_system_prompt" {
command = plan
variables {
agent_id = "test-agent-system-prompt"
workdir = "/home/coder/test"
system_prompt = "Custom addition"
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
assert {
condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have system_prompt variable value"
}
}
run "test_claude_report_tasks_default" {
command = plan
variables {
agent_id = "test-agent-report-tasks"
workdir = "/home/coder/test"
# report_tasks: default is true
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
# Ensure system prompt is wrapped by <system>
assert {
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
error_message = "System prompt should start with <system>"
}
assert {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
# Ensure Coder sections are injected when report_tasks=true (default)
assert {
condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have Tool Selection section"
}
assert {
condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0
error_message = "System prompt should have Task Reporting section"
}
}
run "test_claude_report_tasks_disabled" {
command = plan
variables {
agent_id = "test-agent-report-tasks"
workdir = "/home/coder/test"
report_tasks = false
}
assert {
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
error_message = "System prompt should not be empty"
}
# Ensure system prompt is wrapped by <system>
assert {
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
error_message = "System prompt should start with <system>"
}
assert {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
}
run "test_aibridge_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge"
workdir = "/home/coder/aibridge"
enable_aibridge = true
}
override_data {
@@ -125,8 +306,8 @@ run "test_ai_gateway_enabled" {
}
assert {
condition = var.enable_ai_gateway == true
error_message = "AI Gateway should be enabled"
condition = var.enable_aibridge == true
error_message = "AI Bridge should be enabled"
}
assert {
@@ -136,78 +317,73 @@ run "test_ai_gateway_enabled" {
assert {
condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0
error_message = "ANTHROPIC_BASE_URL should point to AI Gateway endpoint"
error_message = "ANTHROPIC_BASE_URL should point to AI Bridge endpoint"
}
assert {
condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN"
error_message = "ANTHROPIC_AUTH_TOKEN environment variable should be set"
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "CLAUDE_API_KEY environment variable should be set"
}
assert {
condition = coder_env.anthropic_auth_token[0].value == data.coder_workspace_owner.me.session_token
error_message = "ANTHROPIC_AUTH_TOKEN should use workspace owner's session token when ai_gateway is enabled"
}
assert {
condition = length(coder_env.anthropic_api_key) == 0
error_message = "ANTHROPIC_API_KEY env should not be created when ai_gateway is enabled and no anthropic_api_key is provided"
condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token
error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled"
}
}
run "test_ai_gateway_validation_with_api_key" {
run "test_aibridge_validation_with_api_key" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_ai_gateway = true
anthropic_api_key = "test-api-key"
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_aibridge = true
claude_api_key = "test-api-key"
}
expect_failures = [
var.enable_ai_gateway,
var.enable_aibridge,
]
}
run "test_ai_gateway_validation_with_oauth_token" {
run "test_aibridge_validation_with_oauth_token" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_ai_gateway = true
claude_code_oauth_token = "test-auth-token"
enable_aibridge = true
claude_code_oauth_token = "test-oauth-token"
}
expect_failures = [
var.enable_ai_gateway,
var.enable_aibridge,
]
}
run "test_ai_gateway_disabled_with_api_key" {
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent-no-ai-gateway"
workdir = "/home/coder/test"
enable_ai_gateway = false
anthropic_api_key = "test-api-key-xyz"
agent_id = "test-agent-no-aibridge"
workdir = "/home/coder/test"
enable_aibridge = false
claude_api_key = "test-api-key-xyz"
}
assert {
condition = var.enable_ai_gateway == false
error_message = "AI Gateway should be disabled"
condition = var.enable_aibridge == false
error_message = "AI Bridge should be disabled"
}
assert {
condition = coder_env.anthropic_api_key[0].value == "test-api-key-xyz"
error_message = "ANTHROPIC_API_KEY should use the provided API key when ai_gateway is disabled"
condition = coder_env.claude_api_key[0].value == "test-api-key-xyz"
error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled"
}
assert {
condition = length(coder_env.anthropic_base_url) == 0
error_message = "ANTHROPIC_BASE_URL should not be set when ai_gateway is disabled"
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
@@ -215,71 +391,13 @@ run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_ai_gateway = false
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_aibridge = false
}
assert {
condition = length(coder_env.anthropic_api_key) == 0
error_message = "ANTHROPIC_API_KEY should not be created when no API key is provided and ai_gateway is disabled"
}
}
run "test_api_key_count_with_ai_gateway_no_override" {
command = plan
variables {
agent_id = "test-agent-count"
workdir = "/home/coder/test"
enable_ai_gateway = true
}
assert {
condition = length(coder_env.anthropic_auth_token) == 1
error_message = "ANTHROPIC_AUTH_TOKEN env should be created when ai_gateway is enabled"
}
}
run "test_script_outputs_install_only" {
command = plan
variables {
agent_id = "test-agent-outputs"
workdir = "/home/coder/test"
}
assert {
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-claude-code-install_script"
error_message = "scripts output should list only the install script when pre/post are not configured"
}
}
run "test_script_outputs_with_pre_and_post" {
command = plan
variables {
agent_id = "test-agent-outputs-all"
workdir = "/home/coder/test"
pre_install_script = "echo pre"
post_install_script = "echo post"
}
assert {
condition = output.scripts == ["coder-claude-code-pre_install_script", "coder-claude-code-install_script", "coder-claude-code-post_install_script"]
error_message = "scripts output should list pre_install, install, post_install in run order"
}
}
run "test_workdir_optional" {
command = plan
variables {
agent_id = "test-agent-no-workdir"
}
assert {
condition = var.workdir == null
error_message = "workdir should default to null when omitted"
condition = length(coder_env.claude_api_key) == 0
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
}
}
@@ -0,0 +1,240 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d)
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH"
printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM"
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
printf "ARG_MCP: %s\n" "$ARG_MCP"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH"
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' ($source_desc)"
claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..."
echo "------------------------"
echo ""
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if ! grep -q "$path_dir" "$profile" 2> /dev/null; then
echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile"
echo "Added $path_dir to $profile"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$fish_config" ]; then
if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then
echo "fish_add_path $path_dir" >> "$fish_config"
echo "Added $path_dir to $fish_config"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then
CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$CLAUDE_BIN" ] || [ ! -x "$CLAUDE_BIN" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
if [ -n "${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
fi
add_path_to_shell_profiles "$CLAUDE_DIR"
}
function install_claude_code_cli() {
if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
# Use npm when install_via_npm is true
if [ "$ARG_INSTALL_VIA_NPM" = "true" ]; then
echo "WARNING: npm installation method will be deprecated and removed in the next major release."
echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)"
npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION"
echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')"
else
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
CURL_EXIT=${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $CURL_EXIT"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
fi
ensure_claude_in_path
}
function setup_claude_configurations() {
if [ ! -d "$ARG_WORKDIR" ]; then
echo "Warning: The specified folder '$ARG_WORKDIR' does not exist."
echo "Creating the folder..."
mkdir -p "$ARG_WORKDIR"
echo "Folder created successfully."
fi
module_path="$HOME/.claude-module"
mkdir -p "$module_path"
if [ "$ARG_MCP" != "" ]; then
(
cd "$ARG_WORKDIR"
add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR"
)
fi
if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
(
cd "$ARG_WORKDIR"
for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $url"
mcp_json=$(curl -fsSL "$url") || {
echo "Warning: Failed to fetch MCP configuration from '$url', continuing..."
continue
}
if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$mcp_json" "from $url"
done
)
fi
if [ -n "$ARG_ALLOWED_TOOLS" ]; then
coder --allowedTools "$ARG_ALLOWED_TOOLS"
fi
if [ -n "$ARG_DISALLOWED_TOOLS" ]; then
coder --disallowedTools "$ARG_DISALLOWED_TOOLS"
fi
}
function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."
if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
return
fi
local claude_config="$HOME/.claude.json"
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
# Create or update .claude.json with minimal configuration for API key auth
# This skips the interactive login prompt and onboarding screens
if [ -f "$claude_config" ]; then
echo "Updating existing Claude configuration at $claude_config"
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
'.autoUpdaterStatus = "disabled" |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true |
.primaryApiKey = $apikey |
.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo "Creating new Claude configuration at $claude_config"
cat > "$claude_config" << EOF
{
"autoUpdaterStatus": "disabled",
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true,
"primaryApiKey": "${CLAUDE_API_KEY:-}",
"projects": {
"$ARG_WORKDIR": {
"hasCompletedProjectOnboarding": true,
"hasTrustDialogAccepted": true
}
}
}
EOF
fi
echo "Standalone mode configured successfully"
}
function report_tasks() {
if [ "$ARG_REPORT_TASKS" = "true" ]; then
echo "Configuring Claude Code to report tasks via Coder MCP..."
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure claude-code "$ARG_WORKDIR"
else
configure_standalone_mode
fi
}
install_claude_code_cli
setup_claude_configurations
report_tasks
@@ -1,192 +0,0 @@
#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_CLAUDE_CODE_VERSION='${ARG_CLAUDE_CODE_VERSION}'
ARG_WORKDIR='${ARG_WORKDIR}'
ARG_INSTALL_CLAUDE_CODE='${ARG_INSTALL_CLAUDE_CODE}'
ARG_CLAUDE_BINARY_PATH='${ARG_CLAUDE_BINARY_PATH}'
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$${ARG_CLAUDE_CODE_VERSION}"
printf "ARG_WORKDIR: %s\n" "$${ARG_WORKDIR}"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$${ARG_INSTALL_CLAUDE_CODE}"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
echo "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json --scope user \"$${server_name}\" '$${server_json}' ($${source_desc})"
claude mcp add-json --scope user "$${server_name}" "$${server_json}" || echo "Warning: Failed to add MCP server '$${server_name}', continuing..."
echo "------------------------"
echo ""
done < <(echo "$${mcp_json}" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$${profile}" ]; then
if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then
echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}"
echo "Added $${path_dir} to $${profile}"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$${fish_config}" ]; then
if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then
echo "fish_add_path $${path_dir}" >> "$${fish_config}"
echo "Added $${path_dir} to $${fish_config}"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$${ARG_CLAUDE_BINARY_PATH}/claude" ]; then
CLAUDE_BIN="$${ARG_CLAUDE_BINARY_PATH}/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$${CLAUDE_BIN}" ] || [ ! -x "$${CLAUDE_BIN}" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$${CLAUDE_BIN}")
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/claude" ]; then
ln -s "$${CLAUDE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/claude"
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/claude -> $${CLAUDE_BIN}"
fi
add_path_to_shell_profiles "$${CLAUDE_DIR}"
}
function install_claude_code_cli() {
if [ "$${ARG_INSTALL_CLAUDE_CODE}" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$${ARG_CLAUDE_CODE_VERSION}" 2>&1
CURL_EXIT=$${PIPESTATUS[0]}
set -e
if [ $${CURL_EXIT} -ne 0 ]; then
echo "Claude Code installer failed with exit code $${CURL_EXIT}"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
ensure_claude_in_path
}
function setup_claude_configurations() {
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
echo "Warning: The specified folder '$${ARG_WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "$${ARG_WORKDIR}"
echo "Folder created successfully."
fi
module_path="$HOME/.coder-modules/coder/claude-code"
mkdir -p "$${module_path}"
if [ "$${ARG_MCP}" != "" ]; then
add_mcp_servers "$${ARG_MCP}" "from module input"
fi
if [ -n "$${ARG_MCP_CONFIG_REMOTE_PATH}" ] && [ "$${ARG_MCP_CONFIG_REMOTE_PATH}" != "[]" ]; then
for url in $(echo "$${ARG_MCP_CONFIG_REMOTE_PATH}" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $${url}"
mcp_json=$(curl -fsSL "$${url}") || {
echo "Warning: Failed to fetch MCP configuration from '$${url}', continuing..."
continue
}
if ! echo "$${mcp_json}" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$${url}' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$${mcp_json}" "from $${url}"
done
fi
}
function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
return
fi
local claude_config="$HOME/.claude.json"
if [ -f "$${claude_config}" ]; then
echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
else
echo "Creating new Claude configuration at $${claude_config}"
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
EOF
fi
if [ -n "$${ARG_WORKDIR}" ]; then
echo "Pre-accepting trust dialog for $${ARG_WORKDIR}"
jq --arg workdir "$${ARG_WORKDIR}" \
'.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
fi
echo "Standalone mode configured successfully"
}
install_claude_code_cli
setup_claude_configurations
configure_standalone_mode
@@ -0,0 +1,256 @@
#!/bin/bash
set -euo pipefail
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-}
ARG_CONTINUE=${ARG_CONTINUE:-false}
ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"}
ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false}
ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false}
ARG_CODER_HOST=${ARG_CODER_HOST:-}
echo "--------------------------------"
printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID"
printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE"
printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS"
printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE"
printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY"
printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE"
printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY"
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
echo "--------------------------------"
function install_boundary() {
if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ]; then
# Install boundary by compiling from source
echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)"
echo "Removing existing boundary directory to allow re-running the script safely"
if [ -d boundary ]; then
rm -rf boundary
fi
echo "Clone boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary
git checkout "$ARG_BOUNDARY_VERSION"
# Build the binary
make build
# Install binary
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
elif [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then
# Install boundary using official install script
echo "Installing boundary using official install script (version: $ARG_BOUNDARY_VERSION)"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$ARG_BOUNDARY_VERSION"
else
# Use coder boundary subcommand (default) - no installation needed
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
function validate_claude_installation() {
if command_exists claude; then
printf "Claude Code is installed\n"
else
printf "Error: Claude Code is not installed. Please enable install_claude_code or install it manually\n"
exit 1
fi
}
# Hardcoded task session ID for Coder task reporting
# This ensures all task sessions use a consistent, predictable ID
TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
get_project_dir() {
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
echo "$HOME/.claude/projects/${workdir_normalized}"
}
get_task_session_file() {
echo "$(get_project_dir)/${TASK_SESSION_ID}.jsonl"
}
task_session_exists() {
local session_file
session_file=$(get_task_session_file)
if [ -f "$session_file" ]; then
printf "Task session file found: %s\n" "$session_file"
return 0
else
printf "Task session file not found: %s\n" "$session_file"
return 1
fi
}
is_valid_session() {
local session_file="$1"
# Check if file exists and is not empty
# Empty files indicate the session was created but never used so they need to be removed
if [ ! -f "$session_file" ]; then
printf "Session validation failed: file does not exist\n"
return 1
fi
if [ ! -s "$session_file" ]; then
printf "Session validation failed: file is empty, removing stale file\n"
rm -f "$session_file"
return 1
fi
# Check for minimum session content
# Valid sessions need at least 2 lines: initial message and first response
local line_count
line_count=$(wc -l < "$session_file")
if [ "$line_count" -lt 2 ]; then
printf "Session validation failed: incomplete (only %s lines), removing incomplete file\n" "$line_count"
rm -f "$session_file"
return 1
fi
# Validate JSONL format by checking first 3 lines
# Claude session files use JSONL (JSON Lines) format where each line is valid JSON
if ! head -3 "$session_file" | jq empty 2> /dev/null; then
printf "Session validation failed: invalid JSONL format, removing corrupt file\n"
rm -f "$session_file"
return 1
fi
# Verify the session has a valid sessionId field
# This ensures the file structure matches Claude's session format
if ! grep -q '"sessionId"' "$session_file" \
|| ! grep -m 1 '"sessionId"' "$session_file" | jq -e '.sessionId' > /dev/null 2>&1; then
printf "Session validation failed: no valid sessionId found, removing malformed file\n"
rm -f "$session_file"
return 1
fi
printf "Session validation passed: %s\n" "$session_file"
return 0
}
has_any_sessions() {
local project_dir
project_dir=$(get_project_dir)
if [ -d "$project_dir" ] && find "$project_dir" -maxdepth 1 -name "*.jsonl" -size +0c 2> /dev/null | grep -q .; then
printf "Sessions found in: %s\n" "$project_dir"
return 0
else
printf "No sessions found in: %s\n" "$project_dir"
return 1
fi
}
ARGS=()
function start_agentapi() {
# For Task reporting
export CODER_MCP_ALLOWED_TOOLS="coder_report_task"
mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR"
if [ -n "$ARG_PERMISSION_MODE" ]; then
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
fi
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
echo "Resuming specified session: $ARG_RESUME_SESSION_ID"
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
elif [ "$ARG_CONTINUE" = "true" ]; then
if [ "$ARG_REPORT_TASKS" = "true" ]; then
local session_file
session_file=$(get_task_session_file)
if task_session_exists && is_valid_session "$session_file"; then
echo "Resuming task session: $TASK_SESSION_ID"
ARGS+=(--resume "$TASK_SESSION_ID" --dangerously-skip-permissions)
else
echo "Starting new task session: $TASK_SESSION_ID"
ARGS+=(--session-id "$TASK_SESSION_ID" --dangerously-skip-permissions)
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
fi
else
if has_any_sessions; then
echo "Continuing most recent standalone session"
ARGS+=(--continue)
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
else
echo "No sessions found, starting fresh standalone session"
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
fi
fi
else
echo "Continue disabled, starting fresh session"
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
fi
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then
install_boundary
printf "Starting with coder boundary enabled\n"
BOUNDARY_ARGS+=()
# Determine which boundary command to use
if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ] || [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
BOUNDARY_CMD=("boundary")
else
# Use coder boundary subcommand (default)
# Copy coder binary to coder-no-caps. Copying strips CAP_NET_ADMIN capabilities
# from the binary, which is necessary because boundary doesn't work with
# privileged binaries (you can't launch privileged binaries inside network
# namespaces unless you have sys_admin).
CODER_NO_CAPS="$(dirname "$(which coder)")/coder-no-caps"
cp "$(which coder)" "$CODER_NO_CAPS"
BOUNDARY_CMD=("$CODER_NO_CAPS" "boundary")
fi
agentapi server --type claude --term-width 67 --term-height 1190 -- \
"${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
claude "${ARGS[@]}"
else
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
fi
}
validate_claude_installation
start_agentapi
+6 -3
View File
@@ -5,6 +5,9 @@ if [[ "$1" == "--version" ]]; then
exit 0
fi
# Mirror invocation for test assertions and exit cleanly.
echo "claude invoked with: $*"
exit 0
set -e
while true; do
echo "$(date) - claude-mock"
sleep 15
done
+9 -9
View File
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
install_version = "4.106.3"
}
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -72,13 +72,13 @@ module "code-server" {
### Install multiple extensions
Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them to the `extensions` list:
Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust"
}
@@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.4.2"
agent_id = coder_agent.example.id
offline = true
}
+2 -2
View File
@@ -44,7 +44,7 @@ variable "settings" {
default = {}
}
variable "machine_settings" {
variable "machine-settings" {
type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {}
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
@@ -1,101 +0,0 @@
---
display_name: Coder Utils
description: Building block for modules that need orchestrated script execution
icon: ../../../../.icons/coder.svg
verified: false
tags: [internal, library]
---
# Coder Utils
> [!CAUTION]
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
The Coder Utils module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
```tf
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
module_directory = "$HOME/.coder-modules/coder/claude-code"
pre_install_script = <<-EOT
#!/bin/bash
echo "Running pre-install tasks..."
# Your pre-install logic here
EOT
install_script = <<-EOT
#!/bin/bash
echo "Installing dependencies..."
# Your install logic here
EOT
post_install_script = <<-EOT
#!/bin/bash
echo "Running post-install configuration..."
# Your post-install logic here
EOT
start_script = <<-EOT
#!/bin/bash
echo "Starting the application..."
# Your start logic here
EOT
}
```
## Execution Order
The module orchestrates scripts in the following order:
1. **Pre-Install Script** (optional) - Runs before installation
2. **Install Script** (required) - Main installation
3. **Post-Install Script** (optional) - Runs after installation
4. **Start Script** (optional) - Starts the application
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
## Customizing Script Display
By default each `coder_script` renders in the Coder UI as plain "Install Script", "Pre-Install Script", etc. Downstream modules can brand them:
```tf
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
module_directory = "$HOME/.coder-modules/coder/claude-code"
install_script = "echo installing"
display_name_prefix = "Claude Code" # yields "Claude Code: Install Script", etc.
icon = "/icon/claude.svg"
}
```
Both variables are optional. `display_name_prefix` defaults to `""` (no prefix), and `icon` defaults to `null` (use the Coder provider's default).
## Log file locations
The module writes each script's stdout+stderr to `${module_directory}/logs/`:
- `pre_install.log`
- `install.log`
- `post_install.log`
- `start.log`
Each `coder_script` `mkdir -p`s this subdirectory before its `tee` runs, so the first script to execute creates it.
## Script file locations
The module materializes each script to `${module_directory}/scripts/` before running it:
- `pre_install.sh`
- `install.sh`
- `post_install.sh`
- `start.sh`
The pre-install and install `coder_script`s `mkdir -p` this subdirectory; post-install and start sync-depend on install, so the directory already exists by the time they run.
@@ -1,38 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("coder-utils", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: "$HOME/.coder-modules/test/example",
install_script: "echo 'install'",
});
it("rejects invalid module_directory", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: "$HOME/.coder-modules/test",
install_script: "echo 'install'",
});
} catch (ex) {
if (!(ex instanceof Error)) {
throw new Error("Unknown error generated");
}
expect(ex.message).toContain("module_directory must match the pattern");
expect(ex.message).toContain(
"'$HOME/.coder-modules/<namespace>/<module-name>'",
);
return;
}
throw new Error("module_directory validation should have failed");
});
});
-216
View File
@@ -1,216 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the agent used by AgentAPI."
default = null
}
variable "install_script" {
type = string
description = "Script to install the agent used by AgentAPI."
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the agent used by AgentAPI."
default = null
}
variable "start_script" {
type = string
description = "Script that starts AgentAPI."
default = null
}
variable "module_directory" {
type = string
description = "The calling module's working directory. Must follow the pattern '$HOME/.coder-modules/<namespace>/<module-name>'."
validation {
condition = can(regex("^\\$HOME/\\.coder-modules/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$", var.module_directory))
error_message = "module_directory must match the pattern '$HOME/.coder-modules/<namespace>/<module-name>' (e.g. '$HOME/.coder-modules/coder/claude-code')."
}
}
variable "display_name_prefix" {
type = string
description = "Prefix for each coder_script display_name. Example: setting 'Claude Code' yields 'Claude Code: Install Script', 'Claude Code: Pre-Install Script', etc. When unset, scripts show as plain 'Install Script'."
default = ""
}
variable "icon" {
type = string
description = "Icon shown in the Coder UI for every coder_script this module creates. Falls back to the Coder provider's default when unset."
default = null
}
locals {
path_parts = split("/", var.module_directory)
caller_name = "${local.path_parts[length(local.path_parts) - 2]}-${local.path_parts[length(local.path_parts) - 1]}"
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = base64encode(var.install_script)
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
encoded_start_script = var.start_script != null ? base64encode(var.start_script) : ""
pre_install_script_name = "${local.caller_name}-pre_install_script"
install_script_name = "${local.caller_name}-install_script"
post_install_script_name = "${local.caller_name}-post_install_script"
start_script_name = "${local.caller_name}-start_script"
pre_install_path = "${local.scripts_directory}/pre_install.sh"
install_path = "${local.scripts_directory}/install.sh"
post_install_path = "${local.scripts_directory}/post_install.sh"
start_path = "${local.scripts_directory}/start.sh"
pre_install_log_path = "${local.log_directory}/pre_install.log"
install_log_path = "${local.log_directory}/install.log"
post_install_log_path = "${local.log_directory}/post_install.log"
start_log_path = "${local.log_directory}/start.log"
scripts_directory = "${var.module_directory}/scripts"
log_directory = "${var.module_directory}/logs"
install_sync_deps = var.pre_install_script != null ? local.pre_install_script_name : null
start_sync_deps = (
var.post_install_script != null
? "${local.install_script_name} ${local.post_install_script_name}"
: local.install_script_name
)
display_name_prefix = var.display_name_prefix != "" ? "${var.display_name_prefix}: " : ""
}
resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id
display_name = "${local.display_name_prefix}Pre-Install Script"
icon = var.icon
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${var.module_directory}
mkdir -p ${local.scripts_directory}
mkdir -p ${local.log_directory}
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
coder exp sync start ${local.pre_install_script_name}
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
chmod +x ${local.pre_install_path}
${local.pre_install_path} 2>&1 | tee ${local.pre_install_log_path}
EOT
}
resource "coder_script" "install_script" {
agent_id = var.agent_id
display_name = "${local.display_name_prefix}Install Script"
icon = var.icon
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${var.module_directory}
mkdir -p ${local.scripts_directory}
mkdir -p ${local.log_directory}
trap 'coder exp sync complete ${local.install_script_name}' EXIT
%{if local.install_sync_deps != null~}
coder exp sync want ${local.install_script_name} ${local.install_sync_deps}
%{endif~}
coder exp sync start ${local.install_script_name}
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
chmod +x ${local.install_path}
${local.install_path} 2>&1 | tee ${local.install_log_path}
EOT
}
resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "${local.display_name_prefix}Post-Install Script"
icon = var.icon
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
coder exp sync start ${local.post_install_script_name}
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
chmod +x ${local.post_install_path}
${local.post_install_path} 2>&1 | tee ${local.post_install_log_path}
EOT
}
resource "coder_script" "start_script" {
count = var.start_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "${local.display_name_prefix}Start Script"
icon = var.icon
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.start_script_name}' EXIT
coder exp sync want ${local.start_script_name} ${local.start_sync_deps}
coder exp sync start ${local.start_script_name}
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
chmod +x ${local.start_path}
${local.start_path} 2>&1 | tee ${local.start_log_path}
EOT
}
# Filtered, run-order list of the `coder exp sync` names for every
# coder_script this module actually creates. Absent scripts (pre/post/start
# when their inputs are null) are omitted entirely, not padded with empty
# strings. Downstream modules can use this with
# `coder exp sync want <self> <each of these>` to serialize their own
# scripts behind the install pipeline.
output "scripts" {
description = "Ordered list of `coder exp sync` names for the coder_script resources this module creates, in the run order it enforces (pre_install, install, post_install, start). Scripts that were not configured are absent from the list."
value = concat(
var.pre_install_script != null ? [local.pre_install_script_name] : [],
[local.install_script_name],
var.post_install_script != null ? [local.post_install_script_name] : [],
var.start_script != null ? [local.start_script_name] : [],
)
}
@@ -1,628 +0,0 @@
# Test for coder-utils module
# Test with all scripts provided
run "test_with_all_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is created when provided
assert {
condition = length(coder_script.pre_install_script) == 1
error_message = "Pre-install script should be created when pre_install_script is provided"
}
assert {
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
error_message = "Pre-install script agent ID should match input"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
error_message = "Pre-install script should have correct display name"
}
assert {
condition = coder_script.pre_install_script[0].run_on_start == true
error_message = "Pre-install script should run on start"
}
# Verify install_script is always created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script agent ID should match input"
}
assert {
condition = coder_script.install_script.display_name == "Install Script"
error_message = "Install script should have correct display name"
}
assert {
condition = coder_script.install_script.run_on_start == true
error_message = "Install script should run on start"
}
# install should sync-want pre_install
assert {
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script))
error_message = "Install script should sync-want pre_install_script when pre_install is provided"
}
# Verify post_install_script is created when provided
assert {
condition = length(coder_script.post_install_script) == 1
error_message = "Post-install script should be created when post_install_script is provided"
}
assert {
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
error_message = "Post-install script agent ID should match input"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
error_message = "Post-install script should have correct display name"
}
assert {
condition = coder_script.post_install_script[0].run_on_start == true
error_message = "Post-install script should run on start"
}
# Verify start_script is created when provided
assert {
condition = length(coder_script.start_script) == 1
error_message = "Start script should be created when start_script is provided"
}
assert {
condition = coder_script.start_script[0].agent_id == "test-agent-id"
error_message = "Start script agent ID should match input"
}
assert {
condition = coder_script.start_script[0].display_name == "Start Script"
error_message = "Start script should have correct display name"
}
assert {
condition = coder_script.start_script[0].run_on_start == true
error_message = "Start script should run on start"
}
}
run "test_invalid_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test"
install_script = "echo 'install'"
}
expect_failures = [
var.module_directory,
]
}
# Test with only install_script (minimum required input)
run "test_install_only" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
}
# Verify optional scripts are NOT created
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when not provided"
}
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when not provided"
}
assert {
condition = length(coder_script.start_script) == 0
error_message = "Start script should not be created when not provided"
}
# Verify install_script is created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
}
# Test with install and start scripts (no pre/post install)
run "test_install_and_start" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when not provided"
}
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when not provided"
}
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
assert {
condition = length(coder_script.start_script) == 1
error_message = "Start script should be created"
}
assert {
condition = coder_script.start_script[0].agent_id == "test-agent-id"
error_message = "Start script agent ID should match input"
}
# start should sync-want install (no post_install)
assert {
condition = can(regex("sync want test-example-start_script test-example-install_script", coder_script.start_script[0].script))
error_message = "Start script should sync-want install_script"
}
}
# Test with mock data sources
run "test_with_mock_data" {
command = plan
variables {
agent_id = "mock-agent"
module_directory = "$HOME/.coder-modules/test/mock"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
override_data {
target = data.coder_workspace.me
values = {
id = "test-workspace-id"
name = "test-workspace"
owner = "test-owner"
owner_id = "test-owner-id"
template_id = "test-template-id"
template_name = "test-template"
access_url = "https://coder.example.com"
start_count = 1
transition = "start"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
id = "test-owner-id"
email = "test@example.com"
name = "Test User"
session_token = "mock-token"
}
}
override_data {
target = data.coder_task.me
values = {
id = "test-task-id"
}
}
assert {
condition = coder_script.install_script.agent_id == "mock-agent"
error_message = "Install script should use the mocked agent ID"
}
assert {
condition = coder_script.start_script[0].agent_id == "mock-agent"
error_message = "Start script should use the mocked agent ID"
}
}
# Test sync naming derived from module_directory
run "test_script_naming_from_module_directory" {
command = plan
variables {
agent_id = "test-agent"
module_directory = "$HOME/.coder-modules/custom/name"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
assert {
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
error_message = "Install script should derive sync names from module_directory"
}
assert {
condition = can(regex("custom-name-start_script", coder_script.start_script[0].script))
error_message = "Start script should derive sync names from module_directory"
}
}
# Test install syncs with pre_install when provided
run "test_install_syncs_with_pre_install" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
}
assert {
condition = length(coder_script.pre_install_script) == 1
error_message = "Pre-install script should be created"
}
assert {
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script))
error_message = "Install script should sync-want pre_install_script"
}
}
# Test start script sync deps with post_install present
run "test_start_syncs_with_post_install" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# start should sync-want both install and post_install
assert {
condition = can(regex("sync want test-example-start_script test-example-install_script test-example-post_install_script", coder_script.start_script[0].script))
error_message = "Start script should sync-want both install_script and post_install_script"
}
# post_install should sync-want install
assert {
condition = can(regex("sync want test-example-post_install_script test-example-install_script", coder_script.post_install_script[0].script))
error_message = "Post-install script should sync-want install_script"
}
}
# Verify display_name_prefix is prepended to every script's display_name
run "test_display_name_prefix_applied" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
display_name_prefix = "Claude Code"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Claude Code: Pre-Install Script"
error_message = "Pre-install script display_name should be prefixed"
}
assert {
condition = coder_script.install_script.display_name == "Claude Code: Install Script"
error_message = "Install script display_name should be prefixed"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Claude Code: Post-Install Script"
error_message = "Post-install script display_name should be prefixed"
}
assert {
condition = coder_script.start_script[0].display_name == "Claude Code: Start Script"
error_message = "Start script display_name should be prefixed"
}
}
# Verify icon is propagated to every coder_script
run "test_icon_applied" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
icon = "/icon/claude.svg"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
assert {
condition = coder_script.pre_install_script[0].icon == "/icon/claude.svg"
error_message = "Pre-install script icon should match input"
}
assert {
condition = coder_script.install_script.icon == "/icon/claude.svg"
error_message = "Install script icon should match input"
}
assert {
condition = coder_script.post_install_script[0].icon == "/icon/claude.svg"
error_message = "Post-install script icon should match input"
}
assert {
condition = coder_script.start_script[0].icon == "/icon/claude.svg"
error_message = "Start script icon should match input"
}
}
# Verify optional scripts are not created when their variables are unset
run "test_optional_scripts_absent_by_default" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
}
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install coder_script should not be created when pre_install_script is unset"
}
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install coder_script should not be created when post_install_script is unset"
}
assert {
condition = length(coder_script.start_script) == 0
error_message = "Start coder_script should not be created when start_script is unset"
}
}
# Verify `scripts` output is a filtered, run-order list
run "test_scripts_output_with_all" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = length(output.scripts) == 4
error_message = "scripts should have 4 entries when every script is set"
}
assert {
condition = output.scripts[0] == "test-example-pre_install_script"
error_message = "scripts[0] must be the pre-install name"
}
assert {
condition = output.scripts[1] == "test-example-install_script"
error_message = "scripts[1] must be the install name"
}
assert {
condition = output.scripts[2] == "test-example-post_install_script"
error_message = "scripts[2] must be the post-install name"
}
assert {
condition = output.scripts[3] == "test-example-start_script"
error_message = "scripts[3] must be the start name"
}
}
run "test_scripts_output_with_install_only" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
}
assert {
condition = length(output.scripts) == 1
error_message = "scripts should have exactly 1 entry (install) when pre/post/start are unset"
}
assert {
condition = output.scripts[0] == "test-example-install_script"
error_message = "scripts[0] must be the install name"
}
}
run "test_scripts_output_with_install_and_post" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
install_script = "echo install"
post_install_script = "echo post"
}
assert {
condition = length(output.scripts) == 2
error_message = "scripts should have 2 entries (install, post)"
}
assert {
condition = output.scripts[0] == "test-example-install_script"
error_message = "scripts[0] must be the install name"
}
assert {
condition = output.scripts[1] == "test-example-post_install_script"
error_message = "scripts[1] must be the post-install name"
}
}
# Every script must stream combined stdout+stderr to both the agent log
# (via stdout) and the on-disk log file (via tee), so workspace users
# watching `coder_script` output in the UI see progress live and can
# read the same content from the log file after the fact.
run "test_scripts_tee_stdout_and_log_file" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = can(regex("pre_install\\.sh 2>&1 \\| tee .*logs/pre_install\\.log", coder_script.pre_install_script[0].script))
error_message = "pre_install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("install\\.sh 2>&1 \\| tee .*logs/install\\.log", coder_script.install_script.script))
error_message = "install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("post_install\\.sh 2>&1 \\| tee .*logs/post_install\\.log", coder_script.post_install_script[0].script))
error_message = "post_install wrapper must tee combined output to the logs/ subdirectory"
}
assert {
condition = can(regex("start\\.sh 2>&1 \\| tee .*logs/start\\.log", coder_script.start_script[0].script))
error_message = "start wrapper must tee combined output to the logs/ subdirectory"
}
}
# Logs unconditionally land under ${module_directory}/logs/. Each script
# mkdirs that path before tee runs so the first script to execute creates it.
run "test_logs_nested_under_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/pre_install.log")
error_message = "pre_install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.install_script.script, "tee $HOME/.coder-modules/test/example/logs/install.log")
error_message = "install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.post_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/post_install.log")
error_message = "post_install log must land under module_directory/logs"
}
assert {
condition = strcontains(coder_script.start_script[0].script, "tee $HOME/.coder-modules/test/example/logs/start.log")
error_message = "start log must land under module_directory/logs"
}
# Only pre_install and install mkdir the logs/ sub-path. post_install
# and start sync-depend on install so the directory already exists by
# the time they run.
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/logs")
error_message = "pre_install script must mkdir -p the logs/ sub-path"
}
assert {
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/logs")
error_message = "install script must mkdir -p the logs/ sub-path"
}
}
# Scripts unconditionally land under ${module_directory}/scripts/. Each
# script that materializes its own `.sh` file mkdirs that path first; the
# first script to execute creates it for the rest.
run "test_scripts_nested_under_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/test/example"
pre_install_script = "echo pre"
install_script = "echo install"
post_install_script = "echo post"
start_script = "echo start"
}
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/pre_install.sh")
error_message = "pre_install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.install_script.script, "> $HOME/.coder-modules/test/example/scripts/install.sh")
error_message = "install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.post_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/post_install.sh")
error_message = "post_install script must be written under module_directory/scripts"
}
assert {
condition = strcontains(coder_script.start_script[0].script, "> $HOME/.coder-modules/test/example/scripts/start.sh")
error_message = "start script must be written under module_directory/scripts"
}
# Only pre_install and install mkdir the scripts/ sub-path. post_install
# and start sync-depend on install so the directory already exists by
# the time they run.
assert {
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
error_message = "pre_install script must mkdir -p the scripts/ sub-path"
}
assert {
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
error_message = "install script must mkdir -p the scripts/ sub-path"
}
}
@@ -14,9 +14,8 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
start_blocks_login = false
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.34"
agent_id = coder_agent.example.id
}
```
@@ -14,17 +14,10 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
variable "start_blocks_login" {
type = bool
default = false
description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup."
}
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
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
}
+6 -20
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,34 +54,20 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
}
```
## SSH vs HTTPS URLs
If your Git provider (e.g. GitLab, GitHub Enterprise) restricts HTTPS cloning, use an SSH URL instead:
```text
# HTTPS (may fail if HTTP cloning is disabled)
https://gitlab.example.com/user/dotfiles.git
# SSH (uses the workspace's SSH key)
git@gitlab.example.com:user/dotfiles.git
```
When a Git provider has HTTPS cloning disabled server-side, the clone will silently fail (the `.git` folder may exist but the working tree will be empty). SSH URLs avoid this because they authenticate with the workspace's SSH key instead of a token-based HTTPS flow.
## Setting a default dotfiles repository
You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
@@ -90,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.3.0"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+1 -51
View File
@@ -26,7 +26,6 @@ describe("dotfiles", async () => {
"git@github.com:coder/dotfiles.git",
"git://github.com/coder/dotfiles.git",
"ssh://git@github.com/coder/dotfiles.git",
"ssh://git@bitbucket.example.org:7999/~myusername/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
@@ -56,62 +55,13 @@ describe("dotfiles", async () => {
}
});
it("command uses bash for fish shell compatibility", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
manual_update: "true",
dotfiles_uri: "https://github.com/test/dotfiles",
});
const app = state.resources.find(
(r) => r.type === "coder_app" && r.name === "dotfiles",
);
expect(app).toBeDefined();
expect(app?.instances[0]?.attributes?.command).toContain("/bin/bash -c");
});
it("set custom order for coder_parameter", async () => {
const order = 99;
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
coder_parameter_order: order.toString(),
});
expect(state.resources).toHaveLength(3);
const parameters = state.resources.filter(
(r) => r.type === "coder_parameter",
);
for (const param of parameters) {
expect(param.instances[0].attributes.order).toBe(order);
}
});
it("set custom dotfiles_branch", async () => {
const branch = "develop";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_branch: branch,
});
expect(state.resources).toHaveLength(2);
const scriptResource = state.resources.find(
(r) => r.type === "coder_script",
);
expect(scriptResource?.instances[0].attributes.script).toContain(
`DOTFILES_BRANCH="${branch}"`,
);
});
it("default dotfiles_branch creates parameter", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.resources).toHaveLength(3);
const branchParameter = state.resources.find(
(r) =>
r.type === "coder_parameter" &&
r.instances[0].attributes.name === "dotfiles_branch",
);
expect(branchParameter).toBeDefined();
expect(branchParameter?.instances[0].attributes.default).toBeNull();
expect(state.resources[0].instances[0].attributes.order).toBe(order);
});
});
+6 -38
View File
@@ -29,7 +29,7 @@ variable "agent_id" {
variable "description" {
type = string
description = "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning."
default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
}
variable "default_dotfiles_uri" {
@@ -40,18 +40,12 @@ variable "default_dotfiles_uri" {
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.default_dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "default_dotfiles_branch" {
type = string
description = "The default dotfiles branch if the workspace user does not provide one"
default = ""
}
variable "dotfiles_uri" {
type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
@@ -61,23 +55,12 @@ variable "dotfiles_uri" {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.dotfiles_uri))
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "dotfiles_branch" {
type = string
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
default = null
validation {
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
}
}
variable "user" {
type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
@@ -119,26 +102,13 @@ data "coder_parameter" "dotfiles_uri" {
icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$"
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
data "coder_parameter" "dotfiles_branch" {
count = var.dotfiles_branch == null ? 1 : 0
type = "string"
name = "dotfiles_branch"
display_name = "Dotfiles Branch"
order = var.coder_parameter_order
default = var.default_dotfiles_branch
description = "The branch to use for the dotfiles repository"
mutable = true
icon = "/icon/dotfiles.svg"
}
locals {
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
user = var.user != null ? var.user : ""
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
@@ -148,7 +118,6 @@ resource "coder_script" "dotfiles" {
script = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
display_name = "Dotfiles"
@@ -164,12 +133,11 @@ resource "coder_app" "dotfiles" {
icon = "/icon/dotfiles.svg"
order = var.order
group = var.group
command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", {
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
}))} | base64 -d)\""
})
}
output "dotfiles_uri" {
+3 -16
View File
@@ -4,7 +4,6 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
@@ -25,18 +24,10 @@ if [ -n "$${DOTFILES_URI// }" ]; then
DOTFILES_USER="$USER"
fi
if [ -n "$DOTFILES_BRANCH" ]; then
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
else
echo "✨ Applying dotfiles for user $DOTFILES_USER"
fi
echo "✨ Applying dotfiles for user $DOTFILES_USER"
if [ "$DOTFILES_USER" = "$USER" ]; then
if [ -n "$DOTFILES_BRANCH" ]; then
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
else
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
fi
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
else
if command -v getent > /dev/null 2>&1; then
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
@@ -49,11 +40,7 @@ if [ -n "$${DOTFILES_URI// }" ]; then
fi
CODER_BIN=$(command -v coder)
if [ -n "$DOTFILES_BRANCH" ]; then
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
else
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
fi
+4 -6
View File
@@ -14,7 +14,7 @@ A file browser for your workspace.
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5"
version = "1.1.4"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5"
version = "1.1.4"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -41,7 +41,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5"
version = "1.1.4"
agent_id = coder_agent.main.id
database_path = ".config/filebrowser.db"
}
@@ -49,13 +49,11 @@ module "filebrowser" {
### Serve from the same domain (no subdomain)
When `subdomain = false`, you must also set `agent_name` to the name of your `coder_agent` resource. Coder serves path-based apps at `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the agent name is required to build a base URL that matches the URL the user is actually browsing. If `agent_name` is omitted in this mode, `terraform apply` will fail with an explanatory error.
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.5"
version = "1.1.4"
agent_id = coder_agent.main.id
agent_name = "main"
subdomain = false
@@ -102,19 +102,4 @@ describe("filebrowser", async () => {
testBaseLine(output);
}, 15000);
it("fails when subdomain=false and agent_name is not provided", async () => {
let caught: Error | undefined;
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
subdomain: false,
});
} catch (e) {
caught = e as Error;
}
expect(caught).toBeDefined();
expect(caught!.message).toContain("agent_name");
expect(caught!.message).toContain("subdomain");
}, 15000);
});
+1 -8
View File
@@ -20,7 +20,7 @@ data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves."
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
@@ -102,13 +102,6 @@ resource "coder_script" "filebrowser" {
SERVER_BASE_PATH : local.server_base_path
})
run_on_start = true
lifecycle {
precondition {
condition = var.subdomain || var.agent_name != null
error_message = "`agent_name` is required when `subdomain` is `false`. Coder always builds path-based app URLs as `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the filebrowser base URL must include the agent name to match. Set `agent_name = \"<your coder_agent name>\"` (e.g. `\"main\"`)."
}
}
}
resource "coder_app" "filebrowser" {
+11 -34
View File
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}
@@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
@@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
@@ -196,36 +196,13 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
depth = 1
}
```
## Pre-clone script
Run a custom script before cloning the repository by setting the `pre_clone_script` variable.
This is useful for preparing the environment or validating prerequisites before cloning.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT
#!/bin/bash
echo "Preparing to clone repository..."
# Check prerequisites
command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; }
# Set up environment
export NODE_ENV=development
EOT
}
```
## Post-clone script
Run a custom script after cloning the repository by setting the `post_clone_script` variable.
@@ -235,7 +212,7 @@ This is useful for running initialization tasks like installing dependencies or
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
post_clone_script = <<-EOT
@@ -261,16 +261,4 @@ describe("git-clone", async () => {
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script executed");
});
it("runs pre-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script executed'",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script executed");
expect(output.stdout).toContain("Cloning fake-url to ~/fake-url...");
});
});
-9
View File
@@ -68,12 +68,6 @@ variable "post_clone_script" {
default = null
}
variable "pre_clone_script" {
description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists."
type = string
default = null
}
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -95,8 +89,6 @@ locals {
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
# Encode the post_clone_script for passing to the shell script
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
# Encode the pre_clone_script for passing to the shell script
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
}
output "repo_dir" {
@@ -137,7 +129,6 @@ resource "coder_script" "git_clone" {
BRANCH_NAME : local.branch_name,
DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
})
display_name = "Git Clone"
icon = "/icon/git.svg"
-11
View File
@@ -7,7 +7,6 @@ BRANCH_NAME="${BRANCH_NAME}"
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
# Check if the variable is empty...
if [ -z "$REPO_URL" ]; then
@@ -34,16 +33,6 @@ if [ ! -d "$CLONE_PATH" ]; then
mkdir -p "$CLONE_PATH"
fi
# Run pre-clone script if provided
if [ -n "$PRE_CLONE_SCRIPT" ]; then
echo "Running pre-clone script..."
PRE_CLONE_TMP=$(mktemp)
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP"
chmod +x "$PRE_CLONE_TMP"
$PRE_CLONE_TMP
rm "$PRE_CLONE_TMP"
fi
# Check if the directory is empty
# and if it is, clone the repo, otherwise skip cloning
if [ -z "$(ls -A "$CLONE_PATH")" ]; then
+24 -31
View File
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
@@ -52,7 +52,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -66,7 +66,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -75,37 +75,30 @@ module "jetbrains" {
}
```
### Pinned Versions (Air-Gapped / Cached)
When `ide_config` is set, the module makes zero HTTP calls and uses the
provided build numbers directly. This is ideal for air-gapped environments
or when caching IDE installations.
> [!TIP]
> To find the latest build number for an IDE, query the JetBrains releases API:
>
> ```sh
> curl -s "https://data.services.jetbrains.com/products/releases?code=GO&type=release&latest=true" | jq 'to_entries[0].value[0] | {build, version}'
> ```
>
> Replace `GO` with the product code for the IDE you want (e.g. `IU`, `PY`, `CL`).
### Custom IDE Configuration
```tf
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
folder = "/workspace/project"
# Only build is required. Name and icon fall back to built-in defaults.
# Custom IDE metadata (display names and icons)
ide_config = {
"GO" = { build = "261.22158.291" }
"PY" = { build = "261.22158.340" }
# Add entries for other IDEs as needed.
}
"IU" = {
name = "IntelliJ IDEA"
icon = "/custom/icons/intellij.svg"
build = "251.26927.53"
}
options = ["GO", "PY"] # Must match the keys in ide_config.
"PY" = {
name = "PyCharm"
icon = "/custom/icons/pycharm.svg"
build = "251.23774.211"
}
}
}
```
@@ -115,7 +108,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/workspace/project"
@@ -135,7 +128,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons:
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -172,9 +165,9 @@ resource "coder_metadata" "container_info" {
### Version Resolution
- **`ide_config` not set (default)**: Build numbers are fetched from the JetBrains releases API. If the API is unreachable, Terraform will return an error rather than silently using stale versions.
- **`ide_config` set**: The module skips all HTTP calls and uses the provided build numbers directly. No network access required. Ideal for air-gapped deployments or when caching IDE installations.
- `major_version` and `channel` control which API endpoint is queried (only when `ide_config` is not set).
- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet access is available
- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config`
- `major_version` and `channel` control which API endpoint is queried (when API access is available)
## Supported IDEs
@@ -1,3 +1,53 @@
variables {
# Default IDE config, mirrored from main.tf for test assertions.
# If main.tf defaults change, update this map to match.
expected_ide_config = {
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
}
}
run "validate_test_config_matches_defaults" {
command = plan
variables {
# Provide minimal vars to allow plan to read module variables
agent_id = "foo"
folder = "/home/coder"
}
assert {
condition = length(var.ide_config) == length(var.expected_ide_config)
error_message = "Test configuration mismatch: 'var.ide_config' in main.tf has ${length(var.ide_config)} items, but 'var.expected_ide_config' in the test file has ${length(var.expected_ide_config)} items. Please update the test file's global variables block."
}
assert {
# Check that all keys in the test local are present in the module's default
condition = alltrue([
for key in keys(var.expected_ide_config) :
can(var.ide_config[key])
])
error_message = "Test configuration mismatch: Keys in 'var.expected_ide_config' are out of sync with 'var.ide_config' defaults. Please update the test file's global variables block."
}
assert {
# Check if all build numbers in the test local match the module's defaults
# This relies on the previous two assertions passing (same length, same keys)
condition = alltrue([
for key, config in var.expected_ide_config :
var.ide_config[key].build == config.build
])
error_message = "Test configuration mismatch: One or more build numbers in 'var.expected_ide_config' do not match the defaults in 'var.ide_config'. Please update the test file's global variables block."
}
}
run "requires_agent_and_folder" {
command = plan
@@ -209,17 +259,15 @@ run "output_empty_when_default_empty" {
}
}
run "uses_ide_config_when_set" {
run "output_single_ide_uses_fallback_build" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" }
}
# Force HTTP data source to fail to test fallback logic
releases_base_link = "https://coder.com"
}
assert {
@@ -233,38 +281,30 @@ run "uses_ide_config_when_set" {
}
assert {
condition = output.ide_metadata["GO"].name == "GoLand Custom"
error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'"
condition = output.ide_metadata["GO"].name == var.expected_ide_config["GO"].name
error_message = "Expected ide_metadata['GO'].name to be '${var.expected_ide_config["GO"].name}'"
}
assert {
condition = output.ide_metadata["GO"].build == "999.99999.999"
error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'"
condition = output.ide_metadata["GO"].build == var.expected_ide_config["GO"].build
error_message = "Expected ide_metadata['GO'].build to use the fallback '${var.expected_ide_config["GO"].build}'"
}
assert {
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
error_message = "Expected ide_metadata['GO'].icon to be '/icon/goland.svg'"
}
assert {
condition = output.ide_metadata["GO"].json_data == null
error_message = "Expected ide_metadata['GO'].json_data to be null when using ide_config"
condition = output.ide_metadata["GO"].icon == var.expected_ide_config["GO"].icon
error_message = "Expected ide_metadata['GO'].icon to be '${var.expected_ide_config["GO"].icon}'"
}
}
run "uses_ide_config_for_multiple_ides" {
run "output_multiple_ides" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["IU", "PY"]
options = ["IU", "PY"]
ide_config = {
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "111.11111.111" }
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "222.22222.222" }
}
# Force HTTP data source to fail to test fallback logic
releases_base_link = "https://coder.com"
}
assert {
@@ -278,50 +318,15 @@ run "uses_ide_config_for_multiple_ides" {
}
assert {
condition = output.ide_metadata["PY"].name == "PyCharm"
error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'"
condition = output.ide_metadata["PY"].name == var.expected_ide_config["PY"].name
error_message = "Expected ide_metadata['PY'].name to be '${var.expected_ide_config["PY"].name}'"
}
assert {
condition = output.ide_metadata["PY"].build == "222.22222.222"
error_message = "Expected ide_metadata['PY'].build to be the pinned build '222.22222.222'"
}
assert {
condition = output.ide_metadata["IU"].build == "111.11111.111"
error_message = "Expected ide_metadata['IU'].build to be the pinned build '111.11111.111'"
}
assert {
condition = output.ide_metadata["IU"].json_data == null
error_message = "Expected ide_metadata['IU'].json_data to be null when using ide_config"
}
assert {
condition = output.ide_metadata["PY"].json_data == null
error_message = "Expected ide_metadata['PY'].json_data to be null when using ide_config"
condition = output.ide_metadata["PY"].build == var.expected_ide_config["PY"].build
error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'"
}
}
run "ide_config_build_in_url" {
command = apply
variables {
agent_id = "test-agent-123"
folder = "/home/coder/project"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "999.99999.999" }
}
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=999.99999.999", app.url)) > 0])
error_message = "URL must include the pinned build number from ide_config"
}
}
run "validate_output_schema" {
command = plan
@@ -329,10 +334,6 @@ run "validate_output_schema" {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }
}
}
assert {
@@ -350,107 +351,3 @@ run "validate_output_schema" {
error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test."
}
}
run "rejects_major_version_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
major_version = "2025.3"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "rejects_default_not_in_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO", "IU"]
options = ["GO", "IU"]
ide_config = {
"GO" = { build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "ide_config_with_build_only" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
ide_config = {
"GO" = { build = "999.99999.999" }
}
}
assert {
condition = output.ide_metadata["GO"].name == "GoLand"
error_message = "Expected name to fall back to ide_metadata when not set in ide_config"
}
assert {
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
error_message = "Expected icon to fall back to ide_metadata when not set in ide_config"
}
assert {
condition = output.ide_metadata["GO"].build == "999.99999.999"
error_message = "Expected build to use ide_config value"
}
}
run "rejects_releases_base_link_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
releases_base_link = "https://internal.mirror.example.com"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
run "rejects_channel_with_ide_config" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
options = ["GO"]
channel = "eap"
ide_config = {
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
}
}
expect_failures = [
var.ide_config,
]
}
+47 -80
View File
@@ -125,128 +125,95 @@ variable "download_base_link" {
}
data "http" "jetbrains_ide_versions" {
for_each = var.ide_config == null ? local.selected_ides : toset([])
for_each = length(var.default) == 0 ? var.options : var.default
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
}
variable "ide_config" {
description = <<-EOT
Optional map of JetBrains IDE configurations keyed by product code.
When null (default), the module fetches the latest build numbers from
the JetBrains API at plan time. When set, all HTTP calls are skipped
and the provided build numbers are used directly useful for
air-gapped environments or pinning specific versions.
Each value must contain:
- build: Full build number (e.g. "253.28294.337").
Optionally override the default display name or icon:
- name: Display name of the IDE (e.g. "GoLand").
- icon: Path or URL to the IDE icon (e.g. "/icon/goland.svg").
A map of JetBrains IDE configurations.
The key is the product code and the value is an object with the following properties:
- name: The name of the IDE.
- icon: The icon of the IDE.
- build: The build number of the IDE.
Example:
{
"GO" = { build = "261.22158.291" },
"IU" = { build = "261.22158.277" },
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
}
EOT
type = map(object({
name = string
icon = string
build = string
name = optional(string)
icon = optional(string)
}))
default = null
default = {
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
}
validation {
condition = var.ide_config == null || length(var.ide_config) > 0
condition = length(var.ide_config) > 0
error_message = "The ide_config must not be empty."
}
# ide_config must be a superset of var.options
# Requires Terraform 1.9+ for cross-variable validation references
validation {
condition = var.ide_config == null || alltrue([
condition = alltrue([
for code in var.options : contains(keys(var.ide_config), code)
])
error_message = "The ide_config must contain entries for all IDE codes in var.options. Either add the missing entries to ide_config or narrow var.options to match."
}
# ide_config must also cover all codes in var.default to avoid
# key-not-found errors when building options_metadata.
validation {
condition = var.ide_config == null || alltrue([
for code in var.default : contains(keys(var.ide_config), code)
])
error_message = "The ide_config must contain entries for all IDE codes in var.default."
}
# major_version, channel, and releases_base_link only affect the
# HTTP call, which is skipped when ide_config is set. Reject
# non-default values to avoid silently ignoring user intent.
validation {
condition = var.ide_config == null || (
var.major_version == "latest" &&
var.channel == "release" &&
var.releases_base_link == "https://data.services.jetbrains.com"
)
error_message = "major_version, channel, and releases_base_link have no effect when ide_config is set. Remove them or unset ide_config."
error_message = "The ide_config must be a superset of var.options."
}
}
locals {
# Static IDE metadata for name and icon lookups when ide_config is null.
ide_metadata = {
"CL" = { name = "CLion", icon = "/icon/clion.svg" }
"GO" = { name = "GoLand", icon = "/icon/goland.svg" }
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg" }
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg" }
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg" }
"RD" = { name = "Rider", icon = "/icon/rider.svg" }
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg" }
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg" }
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg" }
}
# Determine the user's actual IDE selection.
# This is computed before the HTTP data source so that version lookups
# are only performed for IDEs the user chose not every option.
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
# Parse HTTP responses. Only populated when ide_config is null
# and the module fetches versions from the JetBrains API.
# No try() fallback if the API is expected and fails, Terraform
# should error rather than silently using stale build numbers.
# Parse HTTP responses once with error handling for air-gapped environments
parsed_responses = {
for code, response in data.http.jetbrains_ide_versions :
code => jsondecode(response.response_body)
for code in length(var.default) == 0 ? var.options : var.default : code => try(
jsondecode(data.http.jetbrains_ide_versions[code].response_body),
{} # Return empty object if API call fails
)
}
# Filter the parsed response for the requested major version if not "latest"
filtered_releases = {
for code, parsed in local.parsed_responses : code => [
for r in parsed[keys(parsed)[0]] :
for code in length(var.default) == 0 ? var.options : var.default : code => [
for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) :
r if var.major_version == "latest" || r.majorVersion == var.major_version
]
}
# Select the latest release for the requested major version (first item in the filtered list)
selected_releases = {
for code, releases in local.filtered_releases :
code => length(releases) > 0 ? releases[0] : null
for code in length(var.default) == 0 ? var.options : var.default : code =>
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
}
# Dynamically generate IDE configurations based on selected IDEs
# Dynamically generate IDE configurations based on options with fallback to ide_config
options_metadata = {
for code in local.selected_ides : code => {
icon = var.ide_config != null ? coalesce(var.ide_config[code].icon, local.ide_metadata[code].icon) : local.ide_metadata[code].icon
name = var.ide_config != null ? coalesce(var.ide_config[code].name, local.ide_metadata[code].name) : local.ide_metadata[code].name
for code in length(var.default) == 0 ? var.options : var.default : code => {
icon = var.ide_config[code].icon
name = var.ide_config[code].name
identifier = code
key = code
# When ide_config is set, use the pinned build number directly.
# When fetching from API, use the API result (fails if unavailable).
build = var.ide_config != null ? var.ide_config[code].build : local.selected_releases[code].build
# Use API build number if available, otherwise fall back to ide_config build number
build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build
# API response data, null when using ide_config.
json_data = var.ide_config != null ? null : local.selected_releases[code]
# Store API data for potential future use
json_data = local.selected_releases[code]
}
}
# Convert the parameter value to a set for for_each
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
}
data "coder_parameter" "jetbrains_ides" {
@@ -264,8 +231,8 @@ data "coder_parameter" "jetbrains_ides" {
dynamic "option" {
for_each = var.options
content {
icon = var.ide_config != null ? coalesce(var.ide_config[option.value].icon, local.ide_metadata[option.value].icon) : local.ide_metadata[option.value].icon
name = var.ide_config != null ? coalesce(var.ide_config[option.value].name, local.ide_metadata[option.value].name) : local.ide_metadata[option.value].name
icon = var.ide_config[option.value].icon
name = var.ide_config[option.value].name
value = option.value
}
}
@@ -1,75 +0,0 @@
---
display_name: JFrog Xray
description: Fetch container image vulnerability scan results from JFrog Xray
icon: ../../../../.icons/jfrog-xray.svg
verified: true
tags: [jfrog, xray]
---
# JFrog Xray
This module fetches vulnerability scan results from JFrog Xray for container images stored in Artifactory. Use the outputs to display security information as workspace metadata.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-local/myapp/backend:v1.0.0"
}
resource "coder_metadata" "xray_scan" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
icon = "/icon/shield.svg"
item {
key = "Image"
value = "docker-local/myapp/backend:v1.0.0"
}
item {
key = "Total Vulnerabilities"
value = module.jfrog_xray.total
}
item {
key = "Critical"
value = module.jfrog_xray.critical
}
item {
key = "High"
value = module.jfrog_xray.high
}
item {
key = "Medium"
value = module.jfrog_xray.medium
}
item {
key = "Low"
value = module.jfrog_xray.low
}
}
```
## Prerequisites
1. Container images must be stored in JFrog Artifactory
2. JFrog Xray must be configured to scan your repositories
3. A valid JFrog access token with Xray read permissions
## Remote Repositories
When scanning images from remote (proxy) repositories, set `use_cache_repo = true`. This is because Artifactory stores cached images in a companion `-cache` repository where Xray indexes the scan results.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-remote/library/nginx:latest"
use_cache_repo = true
}
```
@@ -1,244 +0,0 @@
import { serve } from "bun";
import { describe, expect, it } from "bun:test";
import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test";
describe("jfrog-xray", async () => {
await runTerraformInit(import.meta.dir);
// Mock server simulating a local repo with direct scan results
const mockLocalRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "myapp/backend/v1.0.0",
repo_path: "/myapp/backend/v1.0.0/manifest.json",
size: "50.00 MB",
sec_issues: {
critical: 1,
high: 3,
medium: 5,
low: 10,
total: 19,
},
scans_status: {
overall: {
status: "DONE",
time: "2026-03-04T22:00:02Z",
},
},
violations: 0,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server simulating a remote repo with cache behavior
// Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size)
const mockRemoteRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "codercom/enterprise-base/ubuntu",
repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json",
size: "0.00 B",
sec_issues: { total: 0 },
scans_status: {
overall: { status: "DONE" },
},
violations: 0,
},
{
name: "codercom/enterprise-base/sha256__abc123def456",
repo_path:
"/codercom/enterprise-base/sha256__abc123def456/manifest.json",
size: "359.33 MB",
sec_issues: {
critical: 2,
high: 6,
medium: 20,
low: 23,
total: 51,
},
scans_status: {
overall: { status: "DONE" },
},
violations: 2,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server returning empty results (image not scanned)
const mockEmptyResults = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({ data: [], offset: -1 });
return createJSONResponse({});
},
port: 0,
});
const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`;
const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`;
const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`;
const getProviderEnv = (url: string) => ({
XRAY_URL: url,
XRAY_ACCESS_TOKEN: "test-token",
});
it("validates required variable: xray_url", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_token: "test-token",
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_url");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_url" is not set');
}
});
it("validates required variable: xray_token", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_token");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_token" is not set');
}
});
it("validates required variable: image", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without image");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "image" is not set');
}
});
it("returns vulnerability counts for local repository", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "docker-local/myapp/backend:v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.critical.value).toBe(1);
expect(state.outputs.high.value).toBe(3);
expect(state.outputs.medium.value).toBe(5);
expect(state.outputs.low.value).toBe(10);
expect(state.outputs.total.value).toBe(19);
});
it("returns zero counts when image has no scan results", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: emptyResultsUrl,
xray_token: "test-token",
image: "docker-local/unscanned/image:latest",
},
getProviderEnv(emptyResultsUrl),
);
expect(state.outputs.critical.value).toBe(0);
expect(state.outputs.high.value).toBe(0);
expect(state.outputs.medium.value).toBe(0);
expect(state.outputs.low.value).toBe(0);
expect(state.outputs.total.value).toBe(0);
});
it("uses cache repo when use_cache_repo is enabled", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: remoteRepoUrl,
xray_token: "test-token",
image: "docker-remote/codercom/enterprise-base:ubuntu",
use_cache_repo: true,
},
getProviderEnv(remoteRepoUrl),
);
// Should find the SHA artifact with actual vulnerabilities
expect(state.outputs.critical.value).toBe(2);
expect(state.outputs.high.value).toBe(6);
expect(state.outputs.medium.value).toBe(20);
expect(state.outputs.low.value).toBe(23);
expect(state.outputs.total.value).toBe(51);
expect(state.outputs.violations.value).toBe(2);
expect(state.outputs.artifact_name.value).toContain("sha256__");
});
it("allows custom repo and repo_path override", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "ignored/path:tag",
repo: "docker-local",
repo_path: "/myapp/backend/v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.total.value).toBe(19);
});
});
-135
View File
@@ -1,135 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
xray = {
source = "jfrog/xray"
version = ">= 2.0"
}
}
}
provider "xray" {
url = var.xray_url
access_token = var.xray_token
}
variable "xray_url" {
description = "The URL of your JFrog Xray instance (e.g., https://mycompany.jfrog.io/xray). This should point to the Xray API endpoint, not Artifactory."
type = string
validation {
condition = can(regex("^https?://", var.xray_url))
error_message = "The xray_url must be a valid URL starting with http:// or https://."
}
}
variable "xray_token" {
description = "The access token for authenticating with JFrog Xray. This token needs read permissions on Xray scan results. You can generate one in JFrog Platform under User Management > Access Tokens."
type = string
sensitive = true
}
variable "image" {
description = "The Docker image to check for vulnerabilities, in the format 'repo/path/image:tag'. For example: 'docker-local/myapp/backend:v1.0.0' or 'docker-remote/library/nginx:latest'. The repository name is extracted from the first path segment."
type = string
validation {
condition = length(split("/", var.image)) >= 2
error_message = "The image must include at least a repository and image name (e.g., 'docker-local/myimage:tag')."
}
}
variable "repo" {
description = "Override the repository name extracted from the image path. Use this when your Artifactory repository name differs from the first segment of your image path."
type = string
default = ""
}
variable "repo_path" {
description = "Override the full Xray repository path. Use this for custom path structures that don't follow the standard 'repo/image:tag' format. When set, this takes precedence over automatic path construction."
type = string
default = ""
}
variable "use_cache_repo" {
description = "Set to true when scanning images from remote (proxy) repositories. Remote repositories in Artifactory store cached artifacts in a companion '-cache' repository (e.g., 'docker-remote-cache'), which is where Xray indexes the scan results."
type = bool
default = false
}
locals {
# Parse the image string into components
# Example: "docker-local/myapp/backend:v1.0.0"
# -> repo: "docker-local", image_name: "myapp/backend", tag: "v1.0.0"
image_parts = split("/", var.image)
base_repo = var.repo != "" ? var.repo : local.image_parts[0]
parsed_repo = var.use_cache_repo ? "${local.base_repo}-cache" : local.base_repo
image_path = join("/", slice(local.image_parts, 1, length(local.image_parts)))
image_name = split(":", local.image_path)[0]
image_tag = length(split(":", local.image_path)) > 1 ? split(":", local.image_path)[1] : "latest"
# Construct the Xray query path based on repository type:
# - Local repositories: Query the exact tag path (e.g., /myapp/backend/v1.0.0)
# - Remote repositories: Query by image name only (e.g., /myapp/backend) because
# the Terraform provider only returns the SHA manifest (with actual scan data)
# when querying the broader path
parsed_path = var.repo_path != "" ? var.repo_path : (
var.use_cache_repo ? "/${local.image_name}" : "/${local.image_name}/${local.image_tag}"
)
results = coalesce(try(data.xray_artifacts_scan.image_scan.results, []), [])
# For remote repositories, filter to find the actual scanned image (not tag pointers):
# - Tag manifests have size "0.00 B" (they're just pointers to SHA manifests)
# - SHA manifests have actual size (e.g., "359.33 MB") and contain the real scan data
# For local repositories, there's typically only one result which is the actual image
scanned_images = var.use_cache_repo ? [
for r in local.results : r if r.size != "0.00 B"
] : local.results
# The artifact we'll report scan results for
scan_result = (
length(local.scanned_images) > 0 ? local.scanned_images[0] :
length(local.results) > 0 ? local.results[0] :
null
)
}
data "xray_artifacts_scan" "image_scan" {
repo = local.parsed_repo
repo_path = local.parsed_path
}
output "critical" {
description = "The number of critical severity vulnerabilities found in the image. Critical vulnerabilities typically require immediate attention."
value = try(local.scan_result.sec_issues.critical, 0)
}
output "high" {
description = "The number of high severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.high, 0)
}
output "medium" {
description = "The number of medium severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.medium, 0)
}
output "low" {
description = "The number of low severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.low, 0)
}
output "total" {
description = "The total number of vulnerabilities found across all severity levels."
value = try(local.scan_result.sec_issues.total, 0)
}
output "artifact_name" {
description = "The name of the artifact that was scanned, as reported by Xray. For remote repositories, this will be the SHA-based manifest name (e.g., 'myimage/sha256__abc123...')."
value = try(local.scan_result.name, "")
}
output "violations" {
description = "The number of Xray policy violations detected. Violations are triggered when vulnerabilities match rules defined in your Xray security policies."
value = try(local.scan_result.violations, 0)
}
+10 -74
View File
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,40 +63,9 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
```
### Pass Arbitrary `mux server` Arguments
Use `additional_arguments` to append additional arguments to `mux server`.
The module parses quoted values, so grouped arguments remain intact.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
```
### Restart After Mux Exits
Enable automatic restarts after Mux exits, including clean exits and intentional shutdown signals such as `SIGTERM`. The launcher waits for `restart_delay_seconds`, removes `~/.mux/server.lock`, and starts Mux again. Set `max_restart_attempts` to a whole number to stop retrying after a fixed number of restarts, or leave it at `0` for unlimited retries.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
restart_on_kill = true
restart_delay_seconds = 3
max_restart_attempts = 5
add-project = "/path/to/project"
}
```
@@ -106,40 +75,12 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
port = 8080
}
```
### Custom Package Manager
Force a specific package manager instead of auto-detection:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
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.4.3"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
```
### Use Cached Installation
Run an existing copy of Mux if found, otherwise install from npm:
@@ -148,7 +89,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -162,7 +103,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
version = "1.1.0"
agent_id = coder_agent.main.id
install = false
}
@@ -176,9 +117,4 @@ module "mux" {
- Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false)
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Falls back to a direct tarball download when no package manager is found
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits
- Set `max_restart_attempts` to a whole-number cap on restart attempts, or leave it at `0` for unlimited retries
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
+2 -244
View File
@@ -1,11 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -35,7 +30,7 @@ describe("mux", async () => {
}
expect(output.exitCode).toBe(0);
const expectedLines = [
"📥 No package manager found; downloading tarball from registry...",
"📥 npm not found; downloading tarball from npm registry...",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
@@ -45,243 +40,6 @@ describe("mux", async () => {
}
}, 60000);
it("parses custom additional_arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
expect(log).toContain("arg4=--open-mode");
expect(log).toContain("arg5=pinned");
expect(log).toContain("arg6=--add-project");
expect(log).toContain("arg7=/workspaces/my repo");
} finally {
await removeContainer(id);
}
}, 60000);
it("logs signal-based exits after startup", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
});
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
target_pid="$$"
(
sleep 1
kill -9 "$target_pid"
) &
while true; do
sleep 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 2"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("shell exit code 137");
expect(log).toContain(
"SIGKILL usually means the process was killed externally or by the OOM killer.",
);
} finally {
await removeContainer(id);
}
}, 60000);
it("restarts after a clean exit when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
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
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
mkdir -p "$HOME/.mux"
touch "$HOME/.mux/server.lock"
exit 0
fi
if [ -f "$HOME/.mux/server.lock" ]; then
echo "lock=present"
else
echo "lock=cleaned"
fi
exit 0
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 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("mux server exited cleanly.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain(
"Removing /root/.mux/server.lock before restarting mux.",
);
expect(log).toContain("run=2");
expect(log).toContain("lock=cleaned");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("restarts after SIGTERM when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
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
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
kill -TERM $$
fi
exit 0
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 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("signal TERM (15); shell exit code 143.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain("run=2");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
@@ -297,7 +55,7 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);
const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...",
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
+2 -60
View File
@@ -49,69 +49,18 @@ variable "log_path" {
default = "/tmp/mux.log"
}
variable "restart_on_kill" {
type = bool
description = "Restart Mux after it exits by waiting briefly, removing the server lock, and launching it again."
default = false
}
variable "restart_delay_seconds" {
type = number
description = "How long to wait before restarting Mux after it exits when restart_on_kill is enabled."
default = 5
validation {
condition = var.restart_delay_seconds >= 0
error_message = "The 'restart_delay_seconds' variable must be greater than or equal to 0."
}
}
variable "max_restart_attempts" {
type = number
description = "Maximum whole-number restart attempts before giving up. Set to 0 for unlimited restarts when restart_on_kill is enabled."
default = 0
validation {
condition = var.max_restart_attempts >= 0 && floor(var.max_restart_attempts) == var.max_restart_attempts
error_message = "The 'max_restart_attempts' variable must be a whole number greater than or equal to 0."
}
}
variable "add_project" {
variable "add-project" {
type = string
description = "Optional path to add/open as a project in Mux on startup."
default = null
}
variable "additional_arguments" {
type = string
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
default = ""
}
variable "install_version" {
type = string
description = "The version or dist-tag of Mux to install."
default = "next"
}
variable "package_manager" {
type = string
description = "Package manager to install Mux. 'auto' detects npm, pnpm, or bun (falling back to tarball download). Set to 'npm', 'pnpm', or 'bun' to force a specific one."
default = "auto"
validation {
condition = contains(["auto", "npm", "pnpm", "bun"], var.package_manager)
error_message = "The 'package_manager' variable must be one of: 'auto', 'npm', 'pnpm', 'bun'."
}
}
variable "registry_url" {
type = string
description = "The npm-compatible registry URL to install Mux from. Override this for private registries or mirrors."
default = "https://registry.npmjs.org"
}
variable "share" {
type = string
default = "owner"
@@ -182,7 +131,6 @@ resource "random_password" "mux_auth_token" {
locals {
mux_auth_token = random_password.mux_auth_token.result
registry_url = trimsuffix(var.registry_url, "/")
}
resource "coder_script" "mux" {
@@ -193,17 +141,11 @@ resource "coder_script" "mux" {
VERSION : var.install_version,
PORT : var.port,
LOG_PATH : var.log_path,
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
ADDITIONAL_ARGUMENTS : var.additional_arguments,
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
RESTART_ON_KILL : var.restart_on_kill,
RESTART_DELAY_SECONDS : var.restart_delay_seconds,
MAX_RESTART_ATTEMPTS : var.max_restart_attempts,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
run_on_start = true
-230
View File
@@ -79,143 +79,6 @@ run "auth_token_in_url" {
}
}
run "custom_additional_arguments" {
command = plan
variables {
agent_id = "foo"
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
error_message = "mux launch script must include the configured additional arguments"
}
}
run "launcher_logs_external_kills" {
command = plan
variables {
agent_id = "foo"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
error_message = "mux launcher must explain SIGKILL exits in the log"
}
}
run "restart_on_kill_enabled" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"")
error_message = "mux launcher must receive the restart_on_kill setting"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"")
error_message = "mux launcher must receive the configured restart delay"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.")
error_message = "mux launcher must log the restart delay before relaunching"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.")
error_message = "mux launcher must clean up the server lock before relaunching"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128")
error_message = "mux launcher must no longer exclude non-signal exits from restart handling"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "1|2|15)")
error_message = "mux launcher must no longer exclude intentional signals from restart handling"
}
}
run "restart_on_kill_with_restart_cap" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
max_restart_attempts = 2
}
assert {
condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"")
error_message = "mux launcher must receive the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.")
error_message = "mux launcher must describe the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.")
error_message = "mux launcher must log when it hits the restart cap"
}
}
run "invalid_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = -1
}
expect_failures = [
var.max_restart_attempts
]
}
run "fractional_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = 0.5
}
expect_failures = [
var.max_restart_attempts
]
}
run "invalid_restart_delay_seconds" {
command = plan
variables {
agent_id = "foo"
restart_delay_seconds = -1
}
expect_failures = [
var.restart_delay_seconds
]
}
run "custom_version" {
command = plan
@@ -244,96 +107,3 @@ run "use_cached_only_success" {
use_cached = true
}
}
# Custom package_manager should appear in generated script
run "custom_package_manager_npm" {
command = plan
variables {
agent_id = "foo"
package_manager = "npm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_pnpm" {
command = plan
variables {
agent_id = "foo"
package_manager = "pnpm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_bun" {
command = plan
variables {
agent_id = "foo"
package_manager = "bun"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
# Invalid package_manager should fail validation
run "invalid_package_manager" {
command = plan
variables {
agent_id = "foo"
package_manager = "yarn"
}
expect_failures = [
var.package_manager
]
}
# Custom registry_url should appear in generated script
run "custom_registry_url" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com")
error_message = "mux script must use the configured registry URL"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org")
error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set"
}
}
# registry_url trailing slash should be stripped
run "registry_url_trailing_slash" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com/"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/")
error_message = "registry URL trailing slash must be stripped to avoid double slashes"
}
}
+14 -218
View File
@@ -5,195 +5,26 @@ RESET='\033[0m'
MUX_BINARY="${INSTALL_PREFIX}/mux"
function run_mux() {
# Remove stale server lock if present
rm -f "$HOME/.mux/server.lock"
local port_value
local auth_token_value
local restart_on_kill_value
local restart_delay_seconds_value
local max_restart_attempts_value
port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}"
restart_on_kill_value="${RESTART_ON_KILL}"
restart_delay_seconds_value="${RESTART_DELAY_SECONDS}"
max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}"
if [ -z "$port_value" ]; then
port_value="4000"
fi
if [ -z "$restart_delay_seconds_value" ]; then
restart_delay_seconds_value="5"
fi
if [ -z "$max_restart_attempts_value" ]; then
max_restart_attempts_value="0"
fi
mkdir -p "$(dirname "${LOG_PATH}")"
# Build args for mux (POSIX-compatible, avoid bash arrays)
set -- server --port "$port_value"
if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}"
fi
# Parse additional user-supplied server arguments while preserving quoted groups.
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
local parsed_additional_arguments
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
exit 1
fi
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF_ARGS
$${parsed_additional_arguments}
EOF_ARGS
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
echo "️ Mux exit details will be appended to ${LOG_PATH} by the launcher."
if [ "$restart_on_kill_value" = true ]; then
echo "️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay."
if [ "$max_restart_attempts_value" = "0" ]; then
echo "️ Automatic restarts are unlimited for every mux exit."
else
echo "️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts."
fi
fi
nohup env \
LOG_PATH="${LOG_PATH}" \
MUX_BINARY="$MUX_BINARY" \
AUTH_TOKEN="$auth_token_value" \
PORT_VALUE="$port_value" \
RESTART_ON_KILL_VALUE="$restart_on_kill_value" \
RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \
MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
signal_name() {
local signal_number="$1"
local resolved_signal
resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)"
if [ -n "$resolved_signal" ]; then
printf '%s' "$resolved_signal"
return 0
fi
printf 'SIG%s' "$signal_number"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
}
append_kernel_kill_context() {
local mux_pid="$1"
local kernel_context=""
if command -v dmesg > /dev/null 2>&1; then
kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then
kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -n "$kernel_context" ]; then
echo "Recent kernel kill context:"
echo "$kernel_context"
else
echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)."
fi
}
cleanup_mux_lock() {
rm -f "$HOME/.mux/server.lock"
}
should_restart_mux() {
[ "$RESTART_ON_KILL_VALUE" = "true" ]
}
log_mux_exit() {
local mux_pid="$1"
local exit_code="$2"
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
if [ "$exit_code" -eq 0 ]; then
echo "[$timestamp] mux server exited cleanly."
return 0
fi
if [ "$exit_code" -gt 128 ]; then
local signal_number=$((exit_code - 128))
local signal_label
signal_label="$(signal_name "$signal_number")"
echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code."
if [ "$signal_number" -eq 9 ]; then
echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer."
append_kernel_kill_context "$mux_pid"
fi
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
return 0
fi
echo "[$timestamp] mux server exited with code $exit_code."
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
}
log_mux_restart_wait() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited."
}
log_mux_restart_cleanup() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux."
}
log_mux_restart_cap_reached() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again."
}
restart_attempt_count=0
while true; do
cleanup_mux_lock
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
mux_pid=$!
wait "$mux_pid"
exit_code=$?
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
if should_restart_mux; then
if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then
log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1
break
fi
restart_attempt_count=$((restart_attempt_count + 1))
log_mux_restart_wait >> "$LOG_PATH" 2>&1
sleep "$RESTART_DELAY_SECONDS_VALUE"
cleanup_mux_lock
log_mux_restart_cleanup >> "$LOG_PATH" 2>&1
continue
fi
break
done
EOF_LAUNCHER
}
# Check if mux is already installed for offline mode
if [ "${OFFLINE}" = true ]; then
if [ -f "$MUX_BINARY" ]; then
@@ -207,7 +38,7 @@ fi
# If there is no cached install OR we don't want to use a cached install
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
printf "$${BOLD}Installing mux...\n"
printf "$${BOLD}Installing mux from npm...\n"
# Clean up from other install (in case install prefix changed).
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
@@ -216,76 +47,41 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
mkdir -p "$(dirname "$MUX_BINARY")"
# Determine which package manager to use
PM_CMD=""
if [ "${PACKAGE_MANAGER}" = "auto" ]; then
for pm in npm pnpm bun; do
if command -v "$pm" > /dev/null 2>&1; then
PM_CMD="$pm"
break
fi
done
else
PM_CMD="${PACKAGE_MANAGER}"
if ! command -v "$PM_CMD" > /dev/null 2>&1; then
echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH"
exit 1
fi
fi
if [ -n "$PM_CMD" ]; then
echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..."
if command -v npm > /dev/null 2>&1; then
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
NPM_WORKDIR="${INSTALL_PREFIX}/npm"
mkdir -p "$NPM_WORKDIR"
cd "$NPM_WORKDIR" || exit 1
if [ ! -f package.json ]; then
echo '{}' > package.json
fi
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts"
PKG="mux"
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
PKG_SPEC="$PKG@latest"
else
PKG_SPEC="$PKG@${VERSION}"
fi
INSTALL_OK=true
case "$PM_CMD" in
npm)
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
pnpm)
if ! pnpm add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
bun)
if ! bun add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
esac
if [ "$INSTALL_OK" != true ]; then
echo "❌ Failed to install mux via $PM_CMD"
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
echo "❌ Failed to install mux via npm"
exit 1
fi
# Determine the installed binary path
BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
CANDIDATE="$BIN_DIR/mux"
if [ ! -f "$CANDIDATE" ]; then
echo "❌ Could not locate mux binary after $PM_CMD install"
echo "❌ Could not locate mux binary after npm install"
exit 1
fi
chmod +x "$CANDIDATE" || true
ln -sf "$CANDIDATE" "$MUX_BINARY"
else
echo "📥 No package manager found; downloading tarball from registry..."
echo "📥 npm not found; downloading tarball from npm registry..."
VERSION_TO_USE="${VERSION}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE="next"
fi
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
META_JSON="$(curl -fsSL "$META_URL" || true)"
if [ -z "$META_JSON" ]; then
echo "❌ Failed to fetch npm metadata: $META_URL"
@@ -324,7 +120,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
echo "❌ Could not determine version for mux"
exit 1
fi
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
fi
TMP_DIR="$(mktemp -d)"
TAR_PATH="$TMP_DIR/mux.tgz"
@@ -1,46 +0,0 @@
---
display_name: Portable Desktop
description: Install the portabledesktop binary for lightweight Linux desktop sessions.
icon: ../../../../.icons/desktop.svg
verified: true
tags: [desktop, vnc, ai]
---
# Portable Desktop
Install [portabledesktop](https://github.com/coder/portabledesktop) for lightweight Linux desktop sessions over VNC. The binary is stored in the agent's script data directory and is automatically available on PATH via `CODER_SCRIPT_BIN_DIR`.
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
}
```
## Examples
### Custom download URL with checksum verification
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
url = "https://example.com/portabledesktop-linux-x64"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
```
### Additionally copy to a system path
Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory:
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
install_dir = "/usr/local/bin"
}
```

Some files were not shown because too many files have changed in this diff Show More