Compare commits

...

32 Commits

Author SHA1 Message Date
DevCats 6b8d89daba fix(registry/coder-labs/modules/codex): align variable names with claude-code v5 (#885)
Aligns codex module variable names with the claude-code v5 conventions
established in #861 and #879.

- Rename `additional_mcp_servers` to `mcp` to match claude-code's
variable name.
- Change `codex_version` default from `""` to `"latest"` to match
`claude_code_version`.

## Type of Change

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

## Module Information

**Path:** `registry/coder-labs/modules/codex`
**Breaking change:** [x] Yes [ ] No

> [!WARNING]
> Breaking change for anyone referencing `additional_mcp_servers` by
name. Since v5.0.0 was released and deleted on the same day (#879), this
should have zero downstream impact.

## Testing & Validation

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

## Related Issues

- Follow-up to #879
- Filed #886 to track adding `mcp_config_remote_path` support to codex

---
*This PR was authored by Coder Agents.*
2026-05-05 12:31:09 -05:00
35C4n0r c4661ae365 refactor(registry/coder-labs/modules/codex)!: remove agentapi, tasks and start logic (#879)
Closes #878

## What

Major refactor of the `coder-labs/codex` module to mirror the
`coder/claude-code` v5 changes from #861.

## Changes

### Structural
- Replace `module "agentapi"` with `module "coder_utils"`
(`registry.coder.com/coder/coder-utils/coder v0.0.1`)
- Replace `scripts/install.sh` with `scripts/install.sh.tftpl`
(Terraform templatefile)
- Delete `scripts/start.sh`
- Module dir changed from `.codex-module` to
`.coder-modules/coder-labs/codex`
- Output changed from `task_app_id` to `scripts` (ordered list of coder
exp sync names)
- Extracted shared test helpers (`collectScripts`, `runScripts`) into
`agentapi/coder-utils-test-helpers.ts`

### Removed variables
All AgentAPI pass-throughs, boundary, and start-script-only variables:
`order`, `group`, `report_tasks`, `subdomain`, `cli_app`,
`web_app_display_name`, `cli_app_display_name`, `install_agentapi`,
`agentapi_version`, `ai_prompt`, `continue`, `enable_state_persistence`,
`codex_system_prompt`, `enable_boundary`, `boundary_config_path`,
`boundary_version`, `compile_boundary_from_source`,
`use_boundary_directly`, `codex_model`

### Retained
`install_codex` (toggle for skipping npm install when CLI is
pre-installed)

### Renamed
- `enable_aibridge` -> `enable_ai_gateway`

### Changed
- `workdir`: now optional (`default = null`)
- `openai_api_key`: conditional env var with `count`, marked `sensitive
= true`
- `base_config_toml`: heredoc description documenting generated
defaults; notes that `model_reasoning_effort` and workdir trust are only
applied in default config
- Default `config.toml`: stripped `sandbox_mode`, `approval_policy`,
`sandbox_workspace_write`, `notice.model_migrations`
- Install script: removed Node.js/NVM bootstrap (assumes npm
pre-installed), sources NVM if present, fails with actionable error if
npm missing
- `ARG_CODEX_VERSION` and `ARG_WORKDIR` base64-encoded to prevent
shell/TOML injection
- Duplicate `[model_providers.aibridge]` guarded with grep before
appending
- Debug header uses user-facing variable names

### Tests
- Terraform: 11 pass
- Bun: 15 pass (rewritten to shared `collectScripts`/`runScripts`
pattern)
- Added: `model-reasoning-effort-standalone`,
`ai-gateway-with-custom-base-config`,
`ai-gateway-custom-config-no-duplicate-provider`,
`install-codex-latest`, `workdir-trusted-project`,
`no-workdir-no-project-section`
- Negative assertions on `minimal-default-config`

### Docs
- Migration guide (v4 to v5) in README
- Quoted path in coder_app example
- AI Gateway note about custom `base_config_toml` requiring manual
`model_provider`

> [!WARNING]
> Breaking change. Drops support for Coder Tasks and Boundary. Keep
using v4.x.x if you depend on them.

---
*This PR was authored by Coder Agents.*

---------

Co-authored-by: Jay Kumar <jay.kumar@coder.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-05-05 10:10:34 -05:00
blinkagent[bot] 4688e4c1a7 fix(filebrowser): require agent_name when subdomain is false (#877)
## Description

Fixes
[REG-4](https://linear.app/codercom/issue/REG-4/filebrowser-appends-workspace-path-twice-in-url):
the `filebrowser` module opens to a non-existent URL with the workspace
path appended a second time when `subdomain = false` and `agent_name` is
not provided, e.g.:

```
https://<coder-host>/@<owner>/<workspace>/apps/filebrowser/files/@<owner>/<workspace>.<agent>/apps/filebrowser/
```

### Root cause

Coder's frontend always builds path-based app URLs as
`/@<owner>/<workspace>.<agent>/apps/<slug>/` (it always includes
`.<agent_name>`, even for single-agent templates):

https://github.com/coder/coder/blob/main/site/src/modules/apps/apps.ts

```ts
return `${path}/@${workspace.owner_name}/${workspace.name}.${agent.name}/apps/${app.slug}/`;
```

The filebrowser module, however, only includes the agent segment in
`local.server_base_path` (which becomes filebrowser's `--baseURL`) when
the user explicitly passes `agent_name`. The variable description and
the README both said `agent_name` was "only required if the template
uses multiple agents", which is incorrect.

When the URLs disagree, filebrowser's reverse-proxy `stripPrefix` cannot
strip the prefix, the path falls through filebrowser's `/:catchAll(.*)*`
Vue route, and the router redirects to `/files/${catchAll}` — producing
the duplicated path the user reported.

### Fix

- Add a `lifecycle.precondition` on `coder_script.filebrowser` that
fails `terraform apply` with a clear, actionable error when `subdomain =
false` and `agent_name == null`.
- Update the `agent_name` variable description to state it is required
whenever `subdomain` is `false`.
- Update the `README.md` example for the path-based config to call out
the requirement explicitly.
- Bump the module version from `1.1.4` → `1.1.5`.
- Add a TS test covering the new precondition.

This avoids the silent misconfiguration that produces the duplicated
URL, without breaking anyone whose existing template already sets
`agent_name` (or uses `subdomain = true`).

## Type of Change

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

## Module Information

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

## Testing & Validation

- [x] `bun test main.test.ts` — 8 pass, 0 fail (includes new
precondition test)
- [x] `terraform fmt -recursive`
- [x] `terraform validate`
- [x] `bun x prettier --check`
- [x] Manually verified the precondition fires with a minimal repro and
passes when `agent_name` is supplied or `subdomain = true`.

## Related Issues

- Linear:
[REG-4](https://linear.app/codercom/issue/REG-4/filebrowser-appends-workspace-path-twice-in-url)

---

Created on behalf of @matifali.

Generated with Blink.

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-05-04 15:17:50 -05:00
Morgan Lunt 4d96be0de7 feat(claude-code): add telemetry input for OTEL export with workspace attribution (#862)
## Problem

Claude Code ships an OpenTelemetry exporter for token usage, tool calls,
session lifecycle and errors
(https://docs.anthropic.com/en/docs/claude-code/monitoring-usage), but
the module exposes no first-class wiring for it. Template authors who
want telemetry have to know the env var names
(`CLAUDE_CODE_ENABLE_TELEMETRY`, the `OTEL_EXPORTER_OTLP_*` family) and
write their own `coder_env` blocks. More importantly there is no
convention for how to correlate Claude Code telemetry with Coder's own
audit logs and `exectrace` records, so even when both are exported they
end up as two unjoined datasets.

## Change

Adds a `telemetry` input that turns on `CLAUDE_CODE_ENABLE_TELEMETRY`
and the standard OTLP exporter env vars in one place:

```tf
telemetry = {
  enabled       = true
  otlp_endpoint = "http://otel-collector.observability:4317"
  otlp_protocol = "grpc"
  otlp_headers  = { authorization = "Bearer ..." }
  resource_attributes = { "service.name" = "claude-code" }
}
```

When enabled, the module automatically appends `coder.workspace_id`,
`coder.workspace_name`, `coder.workspace_owner` and
`coder.template_name` to `OTEL_RESOURCE_ATTRIBUTES`. This gives a stable
join key between Claude Code spans/metrics and Coder's audit log and
exectrace events on `workspace_id`, so a platform team can answer "show
me every shell command Claude executed in workspace X alongside the
token spend for that session" without custom plumbing.

This is purely additive (`coder_env` resources behind `count`), defaults
to disabled, and is independent of how Claude is launched, so it
composes cleanly with the install-only direction in #861.

## Validation

- `terraform fmt`, `terraform validate`, `terraform test` (19/19) pass
- `bun test -t telemetry` (2/2) pass: env vars are set with the expected
values when enabled, and absent when the input is omitted

Disclosure: I work at Anthropic on the Claude Code team.

---------

Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: Atif Ali <me@matifali.dev>
2026-04-29 13:07:30 -05:00
Atif Ali 3494da4924 docs: standardize module data layout under ~/.coder-modules (#869) 2026-04-24 16:29:45 +00:00
Muhammad Atif Ali b78b65e001 fix(claude-code): correct PR URL in migration guide 2026-04-24 21:17:18 +05:00
Atif Ali 124d05fee9 chore(claude-code)!: strip boundary, agentapi, tasks, tools (#861) 2026-04-24 20:56:32 +05:00
Atif Ali 3b64d99fb1 refactor(registry/coder/modules/coder-utils)!: derive names from module_directory (#874)
## Summary

Derives `coder-utils` script names from `module_directory` instead of a
separate `agent_name` input. The `module_directory` already encodes both
the namespace and the module name, so carrying both is redundant and
error-prone. Callers like `claude-code` no longer need to pass
`agent_name`.

Scripts this module materializes lose the `${agent_name}-utils-` prefix
because `module_directory` already namespaces them per-caller.

We will address multiple instances of coder-utils per caller in a future
iteration if needed.

## Versioning Note

Previous tags (`v1.0.0` through `v1.3.0`) have been deleted because no
published module ever consumed them — the module was effectively
unreleased. This PR ships the first real public version as **`v0.0.1`**,
treating it as a fresh start rather than a breaking bump from a version
that was never in production use.

## Changes

- Remove `agent_name` variable.
- Derive `caller_name = "${namespace}-${module_name}"` from
`module_directory`.
- Validate `module_directory` matches
`$HOME/.coder-modules/<namespace>/<module-name>`.
- Rename script files on disk from `${agent_name}-utils-<phase>.sh` to
plain `<phase>.sh`.
- Add a TS test for the `module_directory` validation.
- Ship as `v0.0.1` (first published version; all prior tags removed).

## Breaking Changes

| Before | After |
|---|---|
| `agent_name = "myagent"` | removed (derived from `module_directory`) |
| `module_directory = ".my-module"` | `module_directory =
"$HOME/.coder-modules/<ns>/<name>"` (validated) |
| Script files `${agent_name}-utils-install.sh` | `install.sh` |
| Script sync names `${agent_name}-install_script` |
`${namespace}-${module_name}-install_script` |

No callers were depending on the old format (prior tags were
unpublished).

## Validation

- `terraform fmt -recursive` clean
- `terraform validate` clean
- `terraform test` → 17/17 pass
- `bun test registry/coder/modules/coder-utils` → 5/5 pass
- `prettier --check` clean

## Consumer

coder/registry#861 (`claude-code`) consumes this and is currently pinned
to the commit SHA until this merges and ships as `v0.0.1`.

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-24 17:16:10 +05:00
Atif Ali 22e574926e feat(coder-utils): nest scripts under module_directory/scripts (#871)
## Summary

Move script files from the flat `${module_directory}` to a `scripts/`
subdirectory, and prefix each script's filename with
`${agent_name}-utils-` so multiple `coder-utils` instances can safely
share a `module_directory`. Mirrors the layout #870 established for
`logs/` and aligns with the Module Data Layout standard in `AGENTS.md`
(#869).

## Changes

- Compute `local.scripts_directory = "${var.module_directory}/scripts"`
and use it for every `*.sh` path.
- Script filenames are now
`${agent_name}-utils-{pre_install,install,post_install,start}.sh` so two
`coder-utils` instances don't collide on disk.
- Pre-install and install `coder_script`s `mkdir -p` the `scripts/`
sub-path before writing their `.sh`; post-install and start sync-depend
on install, so the directory already exists by the time they run.
- Update the `module_directory` description to call out the nested
`scripts/` and `logs/` paths.
- Add `test_scripts_nested_under_module_directory` asserting the new
paths (including the `${agent_name}-utils-` prefix) and the `mkdir -p`
in each script.
- README: add a "Script file locations" section documenting the new
layout.
- Bump module version to `v1.3.0`.

## Breaking Changes

Consumers reading `${module_directory}/install.sh` (and friends)
directly must look under
`${module_directory}/scripts/${agent_name}-utils-install.sh` instead. No
in-repo consumers exist today.

## Validation

- `terraform fmt -recursive` clean
- `terraform validate` clean
- `terraform test` → 16/16 pass (includes the new
`test_scripts_nested_under_module_directory`)
- `bun test main.test.ts` → 5/5 pass
- `prettier --check` clean

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-23 21:46:59 +05:00
Atif Ali f3475c061e feat(coder-utils): nest logs under module_directory/logs (#870) 2026-04-23 11:40:29 +05:00
35C4n0r 39f332fcaf feat(registry/coder/modules/coder-utils): make install_script and start_script optional (#842)
Co-authored-by: Jay Kumar <jay.kumar@coder.com>
Co-authored-by: Atif Ali <atif@coder.com>
2026-04-22 22:53:38 +05:00
Harsh Singh Panwar b108185c14 feature (jetbrains-plugins): add module for installing jetbrains plugin (#772)
Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: DevCats <chris@dualriver.com>
2026-04-22 08:47:53 +05:00
joergklein b72577707c feat(templates): add docker-texlive template with code-server (#828)
## Description

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

## Type of Change

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

## Template Information

**Path:** `registry/joergklein/templates/docker-texlive`

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: DevelopmentCats <christofer@coder.com>
2026-04-20 21:03:12 +00:00
dependabot[bot] 9c01790131 chore(deps): bump the github-actions group with 3 updates (#854)
Bumps the github-actions group with 3 updates:
[coder/coder](https://github.com/coder/coder),
[crate-ci/typos](https://github.com/crate-ci/typos) and
[zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).

Updates `coder/coder` from 2.31.9 to 2.32.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/coder/coder/releases">coder/coder's
releases</a>.</em></p>
<blockquote>
<h2>v2.32.0</h2>
<h2>Changelog</h2>
<blockquote>
<p>[!NOTE]
This is a mainline Coder release. We advise enterprise customers without
a staging environment to install our <a
href="https://github.com/coder/coder/releases/latest">latest stable
release</a> while we refine this version. Learn more about our <a
href="https://coder.com/docs/install/releases">Release Schedule</a>.</p>
</blockquote>
<h3>BREAKING CHANGES</h3>
<ul>
<li>
<p>chore!: remove members' ability to read their own AI Bridge
interceptions (<a
href="https://redirect.github.com/coder/coder/pull/23320">#23320</a>)</p>
<blockquote>
<p>Regular users (non-owners, non-auditors) can no longer read AI Bridge
interception data, including their own. Only owners and auditors retain
read access. This tightens the RBAC surface to prevent insiders from
observing what data is tracked.</p>
</blockquote>
</li>
<li>
<p>fix(cli)!: <code>coder groups list -o json</code> output structure
changed (<a
href="https://redirect.github.com/coder/coder/pull/22923">#22923</a>)</p>
<blockquote>
<p>The JSON output is now a flat structure matching other <code>coder
list -o json</code> commands. Previously this command returned empty
zero-value structs due to a bug, so no working consumer of the old
format could exist.</p>
</blockquote>
</li>
</ul>
<h3>DEPRECATIONS</h3>
<ul>
<li>AI Gateway (previously known as AI Bridge): injected MCP tools are
now deprecated (<a
href="https://redirect.github.com/coder/coder/pull/23031">#23031</a>);
this feature will remain functional but will be replaced with an MCP
Gateway in a future release.</li>
</ul>
<h3>Features</h3>
<h4>Coder Agents</h4>
<p><a href="https://coder.com/docs/ai-coder/agents">Coder Agents</a> is
newly introduced in Early Access. See our <a
href="https://coder.com/docs/ai-coder/agents/getting-started">getting
started guide</a> to enable and start using it.</p>
<ul>
<li>Voice-to-text input in agent chat (<a
href="https://redirect.github.com/coder/coder/pull/23022">#23022</a>)</li>
<li>Pinned chats with drag-to-reorder in the sidebar (<a
href="https://redirect.github.com/coder/coder/pull/23615">#23615</a>)</li>
<li>Chat cost analytics dashboard for admins — tracks spend, model
usage, and trends (<a
href="https://redirect.github.com/coder/coder/pull/23037">#23037</a>, <a
href="https://redirect.github.com/coder/coder/pull/23215">#23215</a>)</li>
<li>PR Insights analytics dashboard — shows PRs created/merged by AI
agents, merge rates, lines shipped, cost per merged PR (<a
href="https://redirect.github.com/coder/coder/pull/23215">#23215</a>)</li>
<li>Agent desktop recordings — record and replay agent desktop sessions
(<a
href="https://redirect.github.com/coder/coder/pull/23894">#23894</a>, <a
href="https://redirect.github.com/coder/coder/pull/23895">#23895</a>)</li>
<li>Per-chat system prompt override per conversation (<a
href="https://redirect.github.com/coder/coder/pull/24053">#24053</a>)</li>
<li>Chat spend limits with inline usage indicator (<a
href="https://redirect.github.com/coder/coder/pull/23071">#23071</a>, <a
href="https://redirect.github.com/coder/coder/pull/23072">#23072</a>) —
configurable via <a
href="https://coder.com/docs/ai-coder/agents/platform-controls">platform
controls</a></li>
<li>Per-user per-model compaction threshold overrides (<a
href="https://redirect.github.com/coder/coder/pull/23412">#23412</a>)</li>
<li>Skills — agents read context files and discover skills locally;
skills persist as message parts (<a
href="https://redirect.github.com/coder/coder/pull/23935">#23935</a>, <a
href="https://redirect.github.com/coder/coder/pull/23748">#23748</a>) —
see <a
href="https://coder.com/docs/ai-coder/agents/extending-agents">extending
agents</a></li>
<li>Suffix-based agent selection — select an agent model by name suffix
(<a
href="https://redirect.github.com/coder/coder/pull/23741">#23741</a>)</li>
<li>Provider key policies and per-user provider settings (<a
href="https://redirect.github.com/coder/coder/pull/23751">#23751</a>) —
see <a href="https://coder.com/docs/ai-coder/agents/models">models &amp;
providers</a></li>
<li>Manual chat title regeneration (<a
href="https://redirect.github.com/coder/coder/pull/23633">#23633</a>)</li>
<li>Chat read/unread indicator in sidebar (<a
href="https://redirect.github.com/coder/coder/pull/23129">#23129</a>)</li>
<li>Chat labels (<a
href="https://redirect.github.com/coder/coder/pull/23594">#23594</a>)</li>
<li>Workspace and agent badges in chat top bar and workspace list (<a
href="https://redirect.github.com/coder/coder/pull/23964">#23964</a>, <a
href="https://redirect.github.com/coder/coder/pull/23453">#23453</a>)</li>
<li>File/image attachments in chat input; large pasted text
auto-converts to file attachments (<a
href="https://redirect.github.com/coder/coder/pull/22604">#22604</a>, <a
href="https://redirect.github.com/coder/coder/pull/23379">#23379</a>)</li>
<li>Inline file reference rendering in user messages (<a
href="https://redirect.github.com/coder/coder/pull/23131">#23131</a>)</li>
<li><code>propose_plan</code> tool for markdown plan proposals (<a
href="https://redirect.github.com/coder/coder/pull/23452">#23452</a>)</li>
<li>Provider-native web search tools in agent chats (<a
href="https://redirect.github.com/coder/coder/pull/22909">#22909</a>)</li>
<li>Workspace awareness system message automatically included on chat
creation (<a
href="https://redirect.github.com/coder/coder/pull/23213">#23213</a>)</li>
<li>Workspace TTL automatically extended on chat heartbeat (<a
href="https://redirect.github.com/coder/coder/pull/23314">#23314</a>)</li>
<li>Global chat workspace TTL deployment-wide setting (<a
href="https://redirect.github.com/coder/coder/pull/23265">#23265</a>)</li>
<li>Template allowlist for chats — restrict which templates agents can
create workspaces from (<a
href="https://redirect.github.com/coder/coder/pull/23262">#23262</a>)</li>
<li>Chat-access site-wide role to gate chat creation (<a
href="https://redirect.github.com/coder/coder/pull/23724">#23724</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/coder/coder/commit/34584e909bbe6f501fb2cbdc994325b4d3f9e2ef"><code>34584e9</code></a>
fix: update to our fork of charm.land/fantasy with appendCompact perf
improve...</li>
<li><a
href="https://github.com/coder/coder/commit/2625056e7108bc66557b67188422b9b924db3b74"><code>2625056</code></a>
fix: backport Go 1.25.9 and dependency fixes (<a
href="https://redirect.github.com/coder/coder/issues/24330">#24330</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/bd1568b0b7ab9164fbe46699403e69c5260c71e5"><code>bd1568b</code></a>
fix: bump coder/tailscale to pick up RTM_MISS fix (cherry-pick <a
href="https://redirect.github.com/coder/coder/issues/24187">#24187</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24214">#24214</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/eb2b1d3a8ba38d9b531e4db405b4c3effe79d136"><code>eb2b1d3</code></a>
fix: update directory for terraform-managed subagents (<a
href="https://redirect.github.com/coder/coder/issues/24220">#24220</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24242">#24242</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/9626fdacad9e12107d173bb19a1d71b666ca0de1"><code>9626fda</code></a>
fix(cli): retry dial timeouts in SSH connection setup (<a
href="https://redirect.github.com/coder/coder/issues/24199">#24199</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24229">#24229</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/52190f032d6f002f5efa2a063c7d99399cabfec8"><code>52190f0</code></a>
fix: revert auto-assign agents-access role enabled (<a
href="https://redirect.github.com/coder/coder/issues/24170">#24170</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24186">#24186</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/8d4148b1986008ed3b9b1cdbc13c35473a7c648b"><code>8d4148b</code></a>
chore: remove kyleosophy easter egg (<a
href="https://redirect.github.com/coder/coder/issues/24174">#24174</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/d3bdd5d1535db5f713634cf781500cae0bddb2ae"><code>d3bdd5d</code></a>
feat: add httproute (<a
href="https://redirect.github.com/coder/coder/issues/23501">#23501</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24172">#24172</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/727ec00f7f693a4edb513013f356340a8acf7564"><code>727ec00</code></a>
chore: revert force deploying main (<a
href="https://redirect.github.com/coder/coder/issues/23290">#23290</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24072">#24072</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/24166">#24166</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/89a0ee3d1d2f61970b2f0856622ca6693eeb62dc"><code>89a0ee3</code></a>
feat: support disabling reverse/local port forwarding in agent SSH
server (<a
href="https://redirect.github.com/coder/coder/issues/2">#2</a>...</li>
<li>Additional commits viewable in <a
href="https://github.com/coder/coder/compare/2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140...34584e909bbe6f501fb2cbdc994325b4d3f9e2ef">compare
view</a></li>
</ul>
</details>
<br />

Updates `crate-ci/typos` from 1.45.0 to 1.45.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/releases">crate-ci/typos's
releases</a>.</em></p>
<blockquote>
<h2>v1.45.1</h2>
<h2>[1.45.1] - 2026-04-13</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Use a temp dir for caching</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/blob/master/CHANGELOG.md">crate-ci/typos's
changelog</a>.</em></p>
<blockquote>
<h1>Change Log</h1>
<p>All notable changes to this project will be documented in this
file.</p>
<p>The format is based on <a href="https://keepachangelog.com/">Keep a
Changelog</a>
and this project adheres to <a href="https://semver.org/">Semantic
Versioning</a>.</p>
<!-- raw HTML omitted -->
<h2>[Unreleased] - ReleaseDate</h2>
<h2>[1.45.1] - 2026-04-13</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Use a temp dir for caching</li>
</ul>
<h2>[1.45.0] - 2026-04-01</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1509">March
2026</a> changes</li>
</ul>
<h2>[1.44.0] - 2026-02-27</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1488">February
2026</a> changes</li>
</ul>
<h2>[1.43.5] - 2026-02-16</h2>
<h3>Fixes</h3>
<ul>
<li><em>(pypi)</em> Hopefully fix the sdist build</li>
</ul>
<h2>[1.43.4] - 2026-02-09</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>pincher</code></li>
</ul>
<h2>[1.43.3] - 2026-02-06</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Adjust how typos are reported to github</li>
</ul>
<h2>[1.43.2] - 2026-02-05</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>certifi</code> in Python</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/cf5f1c29a8ac336af8568821ec41919923b05a83"><code>cf5f1c2</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/485d42553ebf5bd9c810c24c6521bf608d663e70"><code>485d425</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/2fe77ce0ce53ef0ba47e9b371fef1a949baaff3a"><code>2fe77ce</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1539">#1539</a>
from epage/action</li>
<li><a
href="https://github.com/crate-ci/typos/commit/a9595eaf0cc3266bd7fa5c3b2ec7e2a5f3685d18"><code>a9595ea</code></a>
fix(action): Leave binary in temp dir</li>
<li>See full diff in <a
href="https://github.com/crate-ci/typos/compare/02ea592e44b3a53c302f697cddca7641cd051c3d...cf5f1c29a8ac336af8568821ec41919923b05a83">compare
view</a></li>
</ul>
</details>
<br />

Updates `zizmorcore/zizmor-action` from 0.5.2 to 0.5.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/zizmorcore/zizmor-action/releases">zizmorcore/zizmor-action's
releases</a>.</em></p>
<blockquote>
<h2>v0.5.3</h2>
<h2>What's Changed</h2>
<ul>
<li><code>1.24.0</code> and <code>1.24.1</code> are now available via
the action</li>
<li><code>1.24.1</code> is now the default version of zizmor used by the
action</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.5.2...v0.5.3">https://github.com/zizmorcore/zizmor-action/compare/v0.5.2...v0.5.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/b1d7e1fb5de872772f31590499237e7cce841e8e"><code>b1d7e1f</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/102">#102</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/a195b57475917ddcb70845e5ffe1c3a15dbbdedc"><code>a195b57</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/100">#100</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/629d5d01fe5939a6aeae25c1bd1acd2cfa28e9b2"><code>629d5d0</code></a>
chore(deps): bump github/codeql-action in the github-actions group (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/99">#99</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/453d591467e8199b1d5c6883b6ec5c22a12aac72"><code>453d591</code></a>
chore(deps): bump the github-actions group with 2 updates (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/98">#98</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/ea2c18b942410df0b22bed3b94c361c407518d45"><code>ea2c18b</code></a>
Bump pins (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/97">#97</a>)</li>
<li>See full diff in <a
href="https://github.com/zizmorcore/zizmor-action/compare/71321a20a9ded102f6e9ce5718a2fcec2c4f70d8...b1d7e1fb5de872772f31590499237e7cce841e8e">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 08:07:50 +00:00
Atif Ali b9f9fac9ee chore: update devcontainers icon (#850)
Updates the devcontainers icon to use the [Microsoft Fluent UI
`ic_fluent_cube_32_filled`](https://github.com/microsoft/fluentui-system-icons/blob/78c9587b995299d5bfc007a0077773556ecb0994/assets/Cube/SVG/ic_fluent_cube_32_filled.svg),
consistent with
[coder/coder#24478](https://github.com/coder/coder/pull/24478).

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
2026-04-17 18:35:43 +05:00
dependabot[bot] c724684589 chore(deps): bump the github-actions group with 2 updates (#841)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 12:48:07 +05:00
Atif Ali b76b544e78 feat(jetbrains): skip HTTP calls when ide_config is set (#836)
Fixes #835

## Problem

The `data "http"` resource always fires for every selected IDE, even
when the user has pinned versions via `ide_config`. In air-gapped or
caching scenarios, this causes:

- **30-second hangs** when `releases_base_link` is set to a dummy URL
like `https://localhost`
- **Fatal errors** with `https://localhost:1` (connection refused)
- The documented "air-gapped fallback" via `try()` never actually worked
— the `http` data source fails before `try()` can catch anything

## Fix

When `ide_config` is provided, the module now skips all HTTP calls and
uses the pinned build numbers directly.

| Scenario | `ide_config` | HTTP calls | Build source | On API failure |
|---|---|---|---|---|
| User wants latest | `null` (default) | Yes | JetBrains API | Terraform
error (fail loudly) |
| User pins versions | Set | **None** | `ide_config.build` | N/A |

### Changes

- `ide_config` default changed from a full map to `null`
- `name` and `icon` are now `optional(string)` in `ide_config` — falls
back to built-in metadata
- `data.http.jetbrains_ide_versions` `for_each` is empty when
`ide_config` is set
- Static `ide_metadata` local provides name/icon when `ide_config` is
null
- Removed `try()` fallback from `parsed_responses` — API errors are now
explicit instead of silently using stale builds
- Cross-variable validation rejects `major_version`, `channel`, and
`releases_base_link` when `ide_config` is set
- Validation for `ide_config ⊇ default` added (previously only
`ide_config ⊇ options` was checked)
- Version bumped `1.3.1` → `1.4.0`

### Usage

```tf
module "jetbrains" {
  source   = "registry.coder.com/coder/jetbrains/coder"
  version  = "1.4.0"
  agent_id = coder_agent.main.id
  folder   = "/home/coder/project"

  # Zero HTTP calls — only build is required.
  ide_config = {
    "GO" = { build = "261.22158.291" }
    "PY" = { build = "261.22158.340" }
  }
  options = ["GO", "PY"]
}
```

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-09 12:28:57 +05:00
Max Schwenk d3885a5047 feat: add auto permission mode to claude-code module (#830)
## Summary
- Add `auto` as a valid `permission_mode` for the claude-code module,
passing `--enable-auto-mode` to the CLI when selected
- Fix bypass permissions TOS prompt appearing interactively by
pre-seeding `bypassPermissionsModeAccepted` in `~/.claude.json` during
install (workaround for
https://github.com/anthropics/claude-code/issues/25503)
- Bump version `4.8.2` → `4.9.0`

## Test plan
- [x] All 19 terraform tests pass (`terraform test -verbose`)
- [x] Added `test_claude_code_auto_permission_mode` tftest
- [x] Added `claude-auto-permission-mode` TypeScript test verifying both
`--permission-mode auto` and `--enable-auto-mode` are passed
- [ ] Container test with auto mode (requires Linux/Colima)
- [ ] Verify bypass permissions TOS prompt no longer appears on task
startup

🤖 Generated with Claude Code using Claude Opus 4.6

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-04-07 13:59:36 -05:00
dependabot[bot] de7bd01021 chore(deps): bump the github-actions group with 2 updates (#834)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 22:10:34 +05:00
Atif Ali 494ad9bd48 fix(copilot): remove hardcoded model enum to allow any Copilot model (#833)
The `copilot_model` variable was restricted to a hardcoded enum of three
models (`claude-sonnet-4`, `claude-sonnet-4.5`, `gpt-5`). Models change
fast and this validation was blocking users from using newer models.

## Changes

- Remove `validation` block from `copilot_model` variable in `main.tf`
- Update variable description to indicate any Copilot-supported model
can be used
- Replace enum validation test with a test that verifies arbitrary model
strings are accepted
- Bump module version to `0.4.1` in README examples

Closes #832

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑‍💻
2026-04-05 00:42:33 +05:00
Phorcys 5ee68d04d1 feat: add mcp_config input variable to vscode-desktop-core module (#753)
## Description

Standardizes handling of `mcp` variables in VSCode Desktop-based
modules.
Made modular enough to pave the way for setting other config files than
`mcp_server.json` and `mcp.json`.

## Type of Change

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

## Testing & Validation

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

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-03 13:29:46 -05:00
blinkagent[bot] 516a934694 feat(claude-code): wire web_app variable through to agentapi module (#831)
Follow-up to #764.

Now that the `agentapi` module `v2.4.0` is published with `web_app`
support, this PR completes the wiring:

## Changes

### `claude-code/main.tf`
- Bump agentapi dependency from `v2.3.0` → `v2.4.0`
- Replace `# TODO: pass web_app = var.web_app once agentapi module is
published with web_app support` with `web_app = var.web_app`

### `claude-code/README.md`
- Bump version references from `4.9.0` → `4.9.1`

## Result

Setting `web_app = false` on the `claude-code` module now correctly
passes through to the `agentapi` module, hiding the web UI app icon from
the Coder dashboard while still running AgentAPI. The task-safe behavior
(auto-enabling for `coder_ai_task`) is handled by the `agentapi` module.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-03 12:19:05 -05:00
blinkagent[bot] 344b02e4ab feat(agentapi,claude-code): add web_app variable to disable the web app (#764)
Adds a `web_app` variable (default: `true`) to both the `claude-code`
and `agentapi` modules. When set to `false`, AgentAPI still runs but the
web UI app icon is not shown in the Coder dashboard.

This mirrors the existing `cli_app` toggle pattern.

## Changes

### `agentapi` module
- New `web_app` variable (bool, default `true`)
- `coder_app.agentapi_web` now has `count = local.web_app ? 1 : 0`
- **Task-safe:** `local.web_app` is computed as `var.web_app ||
local.is_task`, where `is_task = try(data.coder_task.me.enabled,
false)`. This means the web app is always created when the workspace is
a Task, regardless of the `web_app` variable.
- `task_app_id` output returns `""` when `local.web_app` is `false`

### `claude-code` module
- New `web_app` variable (bool, default `true`)
- `TODO` comment to wire `web_app` through to agentapi once published

## Usage (once fully wired)

```hcl
module "claude-code" {
  source  = "registry.coder.com/coder/claude-code/coder"
  ...
  web_app = false  # hides the Claude Code web UI from the dashboard
}
```

Setting `web_app = false` is safe even in templates that use
`coder_ai_task` — the module detects Tasks via
`data.coder_task.me.enabled` and automatically enables the web app.

## Merge strategy

This needs to land in two steps:
1. **Merge this PR** — publishes the agentapi module with `web_app`
support, and adds the `web_app` variable to claude-code (not yet wired
through)
2. **Follow-up PR** — bump the agentapi version in claude-code and
replace the `TODO` with `web_app = var.web_app`

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-04-03 12:00:02 -05:00
Seth Shelnutt 31a07ac823 feat(templates): add docker-rstudio template with code-server and RMarkdown (#819)
## New Template: docker-rstudio

Adds a Docker-based template for R development workspaces.

### What it provides

| Tool | Source | Access |
|------|--------|--------|
| **RStudio Server** | Pre-installed in `rocker/rstudio` image | Browser
via Coder proxy (subdomain) |
| **code-server** | `registry.coder.com/coder/code-server/coder` module
| Browser via Coder proxy |
| **RMarkdown** | Installed on first start, persisted in home-dir R
library | Available in both RStudio and code-server |

### Design decisions

<details>
<summary>Click to expand</summary>

- **`rocker/rstudio` as the base image** instead of
`codercom/enterprise-base:ubuntu` + the `rstudio-server` module. The
module runs RStudio inside a nested Docker container which requires
Docker-in-Docker or socket mounting in the workspace. Using the rocker
image directly avoids that complexity and starts faster since R and
RStudio are already installed.
- **Direct `coder_app` for RStudio** rather than the registry
`rstudio-server` module, because the module is designed for Docker-based
provisioning (it pulls and runs a rocker container). Since the workspace
itself _is_ the rocker container, RStudio Server is started natively via
`rserver`.
- **RMarkdown installed idempotently** — the startup script checks
`require('rmarkdown')` before installing. Since R libraries default to a
subdirectory under `/home/rstudio` (the persistent volume), packages
survive workspace restarts.
- **Persistent volume mounted at `/home/rstudio`** to match the default
user in the rocker image.
- **`--auth-none=1`** disables RStudio authentication since the Coder
proxy handles access control.

</details>

### Files added

- `registry/coder/templates/docker-rstudio/main.tf`
- `registry/coder/templates/docker-rstudio/README.md`

### Validation

- `go run ./cmd/readmevalidation/` — passes (32 templates detected)
- `terraform fmt` — clean
- `bun run fmt` — all files unchanged

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-03 11:51:35 -05:00
DevCats 5973739f41 feat: add coder-modules and coder-templates skills for creating and updating modules and templates (#813)
## Description

Add two Claude Code skills for the Coder Registry: `coder-modules` and
`coder-templates`. These skills guide AI agents through creating and
updating registry modules and workspace templates, covering scaffolding,
Terraform patterns, testing, README standards, icon management, version
bumps, and newer features like presets, prebuilds, and task-oriented
templates.
2026-04-02 20:14:59 +00:00
DevCats ad61bddfb2 chore: fix module reference in coder-utils (#826)
## Description

fix module reference in coder-utils
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information


**Path:** `registry/coder/modules/coder-utils`  
**New version:** `v1.0.1`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2026-04-02 07:56:09 -05:00
Ben Potter eea5b24e3d fix: onepassword module resource naming and add demo screenshot (#827)
Fixes `coder_script "1password"` → `coder_script "onepassword"` since
Terraform resource names cannot start with a digit. Adds a demo
screenshot showing the template variables page and `op whoami` working
in a workspace. Bumps version to 1.0.2.
2026-04-01 17:56:45 -05:00
Ben Potter ee035ee9b9 fix: use 1Password brand blue icon for dark background visibility (#825)
The 1Password icon was black on transparent, making it invisible on the
registry's dark cards. Replaced with 1Password brand blue (`#0572EC`)
circle + white keyhole.
2026-04-01 18:55:22 +00:00
Ben Potter 5bc668aa4d feat: add 1password module under bpmct namespace (#824)
Adds a 1Password module under the `bpmct` namespace.

## What it does

Installs the [1Password CLI](https://developer.1password.com/docs/cli/)
(`op`) into Coder workspaces at startup. Two auth paths:

- **Service account token** — set `service_account_token` and
`OP_SERVICE_ACCOUNT_TOKEN` is injected automatically. Fully headless.
- **Personal account** — set `account_address`, `account_email`,
`account_secret_key` to pre-register the account. User runs `op signin`
in their terminal.

Optionally installs the [1Password VS Code
extension](https://marketplace.visualstudio.com/items?itemName=1Password.op-vscode)
(`1Password.op-vscode`) for code-server and VS Code with
`install_vscode_extension = true`.

Supports `pre_install_script` and `post_install_script` for custom
orchestration.

## What's included

- `registry/bpmct/` — new namespace (Ben Potter, community)
- `registry/bpmct/modules/1password/` — the module (`main.tf`, `run.sh`,
`README.md`)
- `.icons/1password.svg` — 1Password logo from Simple Icons

## Tested

Spun up a dev Coder instance, pushed the template with a real 1Password
service account token, created a workspace, and confirmed:

- `op` CLI installs and authenticates
- `op vault list` returns vaults
- `1Password.op-vscode` extension installs in code-server

---------

Co-authored-by: DevCats <christofer@coder.com>
2026-04-01 18:38:27 +00:00
DevCats caaff0c1e9 chore: rename agent-helper to coder-helper (#816)
## Description

Change `agent-helper` to `coder-utils`

The current tag for agent-helper needs to be deleted before this PR is
merged.

## Type of Change

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

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/coder-utils`  
**New version:** `v1.0.0`  
**Breaking change:** [X] Yes [ ] No ( Module name is changing, but this
is not nested in any modules yet )

## Testing & Validation

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

## Related 

https://github.com/coder/registry/pull/802
2026-04-01 18:31:36 +00:00
blinkagent[bot] 057d7396ea fix(jetbrains): correct version bump to patch (1.3.1) instead of minor (1.4.0) (#823)
PR #822 bumped the jetbrains module version from `1.3.0` to `1.4.0`
(minor), but the change was a bugfix and should have been a patch bump.

This corrects all 7 version references in the README from `1.4.0` to
`1.3.1`.

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-04-01 10:40:29 -05:00
Atif Ali fc66478b94 fix(jetbrains): scope HTTP version fetch to selected IDEs only (#822)
## Problem

The `data "http" "jetbrains_ide_versions"` resource fetches release info
from `data.services.jetbrains.com` for **all configured IDE options** at
plan time, regardless of what the user actually selected. When the API
is unreachable (air-gapped environments, DNS failures, transient
outages), this causes a fatal Terraform error that blocks the workspace
build — even when no JetBrains IDEs were selected.

## Fix

Changed the `for_each` on the HTTP data source (and all dependent
locals) from iterating over `var.options`/`var.default` to
`local.selected_ides` — the user's actual selection.

| Scenario | Before | After |
|---|---|---|
| No IDEs selected (`[]`) | 9 HTTP requests | 0 HTTP requests |
| 1 IDE selected (`["GO"]`) | 9 HTTP requests | 1 HTTP request |
| All IDEs selected | 9 HTTP requests | 9 HTTP requests |

## Validation

- All 17 existing `terraform test` cases pass
- Tested end-to-end on [dev.coder.com](https://dev.coder.com) with
Docker template:
  - `jetbrains_ides=[]` — zero HTTP requests, build succeeds
- `jetbrains_ides=["GO"]` — single HTTP request for GoLand only,
`coder_app.jetbrains["GO"]` created

Closes #821

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
2026-04-01 10:33:03 -05:00
68 changed files with 5760 additions and 3707 deletions
+369
View File
@@ -0,0 +1,369 @@
---
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
@@ -0,0 +1,321 @@
---
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
@@ -0,0 +1 @@
../.agents/skills
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
@@ -87,13 +87,13 @@ jobs:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
with:
config: .github/typos.toml
validate-readme-files:
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@f7650296ceb9b020c79cd525ac7bd3c7f252ae1d # v2.31.6
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies
run: bun install
@@ -62,7 +62,7 @@ jobs:
- name: Comment on PR - Version bump required
if: failure()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.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@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with:
advanced-security: false
annotations: true
@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with:
inputs: |
.github/workflows
+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 999 B

+3 -1
View File
@@ -1 +1,3 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 834 B

+315
View File
@@ -0,0 +1,315 @@
<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>

After

Width:  |  Height:  |  Size: 127 KiB

+100
View File
@@ -21,6 +21,84 @@ 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
@@ -31,6 +109,28 @@ bun test main.test.ts # Run single TS test (from
- **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
- Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking)
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+11
View File
@@ -0,0 +1,11 @@
---
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.
@@ -0,0 +1,97 @@
---
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
@@ -0,0 +1,112 @@
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
@@ -0,0 +1,176 @@
#!/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"
+87 -141
View File
@@ -1,149 +1,107 @@
---
display_name: Codex CLI
icon: ../../../../.icons/openai.svg
description: Run Codex CLI in your workspace with AgentAPI integration
description: Install and configure the Codex CLI in your workspace.
verified: true
tags: [agent, codex, ai, openai, tasks, aibridge]
tags: [agent, codex, ai, openai, ai-gateway]
---
# Codex CLI
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.
Install and configure the [Codex CLI](https://github.com/openai/codex) in your workspace.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.example.id
version = "5.0.0"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
}
```
## Prerequisites
- OpenAI API key for Codex access
> [!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.
## Examples
### Run standalone
### Standalone mode with a launcher app
```tf
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
report_tasks = false
locals {
codex_workdir = "/home/coder/project"
}
```
### 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.3.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 aibridge model_provider 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_provider = "aibridge"
[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"
```
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 = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
workdir = "/home/coder/project"
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
version = "5.0.0"
agent_id = coder_agent.main.id
workdir = local.codex_workdir
openai_api_key = var.openai_api_key
}
```
### Usage with Agent Boundaries
This example shows how to configure the Codex 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
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
enable_boundary = true
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
}
```
> [!NOTE]
> 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.
> 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 = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
version = "5.0.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
openai_api_key = var.openai_api_key
codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
codex_version = "0.128.0"
# Override default configuration
base_config_toml = <<-EOT
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
EOT
# Add extra MCP servers
additional_mcp_servers = <<-EOT
mcp = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
@@ -152,61 +110,49 @@ module "codex" {
}
```
> [!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.
### Serialize a downstream `coder_script` after the install pipeline
## How it Works
- **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.
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
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.
```tf
module "codex" {
# ... other config
enable_state_persistence = false
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
}
```
## Configuration
### 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).
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).
## Troubleshooting
- Check installation and startup logs in `~/.codex-module/`
- Ensure your OpenAI API key has access to the specified model
Check the log files in `~/.coder-modules/coder-labs/codex/logs/` for detailed information.
> [!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.
```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
```
## References
- [Codex CLI Documentation](https://github.com/openai/codex)
- [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)
- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway)
+340 -409
View File
@@ -6,15 +6,67 @@ import {
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
execContainer,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
TerraformState,
} from "~test";
import {
extractCoderEnvVars,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../../../coder/modules/agentapi/test-util";
import dedent from "dedent";
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;
};
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
@@ -33,36 +85,90 @@ afterEach(async () => {
});
interface SetupProps {
skipAgentAPIMock?: boolean;
skipCodexMock?: boolean;
moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
}
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const setup = async (
props?: SetupProps,
): Promise<{
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
const projectDir = "/home/coder/project";
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,
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",
});
if (!props?.skipCodexMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/codex",
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
content: await Bun.file(
path.join(moduleDir, "testdata", "codex-mock.sh"),
).text(),
});
}
return { id };
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}`);
}
}
};
setDefaultTimeout(60 * 1000);
@@ -73,444 +179,269 @@ describe("codex", async () => {
});
test("happy-path", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
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");
});
test("install-codex-version", async () => {
const version_to_install = "0.10.0";
const { id } = await setup({
const version = "0.10.0";
const { id, coderEnvVars, scripts } = await setup({
skipCodexMock: true,
moduleVariables: {
install_codex: "true",
codex_version: version_to_install,
codex_version: 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);
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(installLog).toContain(version);
});
test("check-latest-codex-version-works", async () => {
const { id } = await setup({
skipCodexMock: true,
skipAgentAPIMock: true,
moduleVariables: {
install_codex: "true",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("base-config-toml", async () => {
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 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("codex-api-key", async () => {
test("openai-api-key", async () => {
const apiKey = "test-api-key-123";
const { id } = await setup({
const { coderEnvVars } = await setup({
moduleVariables: {
openai_api_key: apiKey,
},
});
await execModuleScript(id);
expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey);
});
const resp = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(resp).toContain("OpenAI API Key: Provided");
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({
moduleVariables: {
base_config_toml: baseConfig,
},
});
await runScripts(id, scripts);
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]");
});
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({
moduleVariables: {
mcp: additional,
},
});
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");
});
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");
});
test("pre-post-install-scripts", async () => {
const { id } = await setup({
const { id, scripts } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
post_install_script: "#!/bin/bash\necho 'post-install-script'",
pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'codex-post-install-script'",
},
});
await execModuleScript(id);
await runScripts(id, scripts);
const preInstallLog = await readFileContainer(
id,
"/home/coder/.codex-module/pre_install.log",
"/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log",
);
expect(preInstallLog).toContain("pre-install-script");
expect(preInstallLog).toContain("codex-pre-install-script");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.codex-module/post_install.log",
"/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log",
);
expect(postInstallLog).toContain("post-install-script");
expect(postInstallLog).toContain("codex-post-install-script");
});
test("workdir-variable", async () => {
const workdir = "/tmp/codex-test-workdir";
const { id } = await setup({
skipCodexMock: false,
const workdir = "/home/coder/codex-test-folder";
const { id, scripts } = await setup({
moduleVariables: {
workdir,
},
});
await execModuleScript(id);
const resp = await readFileContainer(
await runScripts(id, scripts);
const installLog = await readFileContainer(
id,
"/home/coder/.codex-module/install.log",
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
);
expect(resp).toContain(workdir);
expect(installLog).toContain(workdir);
});
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({
test("codex-with-ai-gateway", async () => {
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: {
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",
enable_ai_gateway: "true",
model_reasoning_effort: "none",
},
});
await execModuleScript(id);
await runScripts(id, scripts, coderEnvVars);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain('model_provider = "aibridge"');
expect(configToml).toContain('model_provider = "aigateway"');
expect(configToml).toContain('model_reasoning_effort = "none"');
expect(configToml).toContain("[model_providers.aigateway]");
});
test("boundary-enabled", async () => {
const { id } = await setup({
test("model-reasoning-effort-standalone", async () => {
const { id, scripts } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
model_reasoning_effort: "high",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.openai.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
if [ "$2" = "--help" ]; then
echo "boundary help"
exit 0
fi
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify boundary wrapper was used in start script
const startLog = await readFileContainer(
await runScripts(id, scripts);
const configToml = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("boundary");
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(`[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");
});
});
+89 -230
View File
@@ -18,18 +18,6 @@ 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."
@@ -38,106 +26,8 @@ variable "icon" {
variable "workdir" {
type = string
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 model. One of: none, low, medium, high. 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, 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.12.1"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.4"
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
}
variable "pre_install_script" {
@@ -152,158 +42,127 @@ variable "post_install_script" {
default = null
}
variable "ai_prompt" {
type = string
description = "Initial task prompt for Codex CLI when launched via Tasks"
default = ""
}
variable "continue" {
variable "install_codex" {
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)."
description = "Whether to install Codex."
default = true
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
variable "codex_system_prompt" {
variable "codex_version" {
type = string
description = "System instructions written to AGENTS.md in the ~/.codex directory"
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering."
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."
description = "The version of Codex to install."
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 "openai_api_key" {
type = string
description = "OpenAI API key for Codex CLI."
sensitive = true
default = ""
}
variable "use_boundary_directly" {
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 = "Whether to use boundary binary directly instead of coder boundary subcommand."
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."
}
}
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
}
resource "coder_env" "coder_aibridge_session_token" {
count = var.enable_aibridge ? 1 : 0
# 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
agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token
}
locals {
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.4"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
aibridge_config = <<-EOF
[model_providers.aigateway]
name = "AI Gateway"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
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 "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.3.0"
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
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
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
enable_boundary = var.enable_boundary
boundary_config_path = var.boundary_config_path
boundary_version = var.boundary_version
compile_boundary_from_source = var.compile_boundary_from_source
use_boundary_directly = var.use_boundary_directly
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_CODEX_MODEL='${var.codex_model}' \
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
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_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
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
}
output "task_app_id" {
value = module.agentapi.task_app_id
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
}
+171 -173
View File
@@ -1,187 +1,185 @@
run "test_codex_basic" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.agent_id == "test-agent"
error_message = "Agent ID should be set correctly"
}
assert {
condition = var.workdir == "/home/coder"
error_message = "Workdir should be set correctly"
}
assert {
condition = var.install_codex == true
error_message = "install_codex 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"
}
assert {
condition = var.continue == true
error_message = "continue should default to true"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_codex_with_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_aibridge = true
}
assert {
condition = var.enable_aibridge == true
error_message = "enable_aibridge should be set to true"
}
}
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_aibridge = false
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should be false"
}
assert {
condition = coder_env.openai_api_key.value == "test-key"
error_message = "OpenAI API key should be set correctly"
}
}
run "test_custom_options" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder/project"
openai_api_key = "test-key"
order = 5
group = "ai-tools"
icon = "/icon/custom.svg"
web_app_display_name = "Custom Codex"
cli_app = true
cli_app_display_name = "Codex Terminal"
subdomain = true
report_tasks = false
continue = false
codex_model = "gpt-4o"
codex_version = "0.1.0"
agentapi_version = "v0.12.0"
}
assert {
condition = var.order == 5
error_message = "Order should be set to 5"
}
assert {
condition = var.group == "ai-tools"
error_message = "Group should be set to 'ai-tools'"
}
assert {
condition = var.icon == "/icon/custom.svg"
error_message = "Icon should be set to custom icon"
}
assert {
condition = var.cli_app == true
error_message = "cli_app should be enabled"
}
assert {
condition = var.subdomain == true
error_message = "subdomain should be enabled"
}
assert {
condition = var.report_tasks == false
error_message = "report_tasks should be disabled"
}
assert {
condition = var.continue == false
error_message = "continue should be disabled"
}
assert {
condition = var.codex_model == "gpt-4o"
error_message = "codex_model should be set to 'gpt-4o'"
}
}
run "test_no_api_key_no_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.openai_api_key == ""
error_message = "openai_api_key should be empty when not provided"
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 = var.enable_aibridge == false
error_message = "enable_aibridge should default to false"
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"
}
}
@@ -1,228 +0,0 @@
#!/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 "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
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"
ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"'
fi
if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\""
fi
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
[projects."${ARG_CODEX_START_DIRECTORY}"]
trust_level = "trusted"
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
@@ -0,0 +1,195 @@
#!/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
@@ -1,229 +0,0 @@
#!/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 [[ -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_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
# enable_boundary=true. It points to a wrapper script that runs the command
# through coder boundary, sandboxing only the agent process.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
printf "Starting with coder boundary enabled\n"
agentapi server --type codex --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
else
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
fi
capture_session_id
}
validate_codex_installation
setup_workdir
build_codex_args
start_codex
+2 -31
View File
@@ -1,38 +1,9 @@
#!/bin/bash
# Handle --version flag
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "codex version v1.0.0"
exit 0
fi
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
echo "codex invoked with: $*"
exit 0
@@ -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.0"
version = "0.4.1"
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.0"
version = "0.4.1"
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.0"
version = "0.4.1"
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.0"
version = "0.4.1"
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.0"
version = "0.4.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
@@ -179,7 +179,7 @@ module "aibridge-proxy" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
version = "0.4.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
enable_aibridge_proxy = true
@@ -117,18 +117,23 @@ run "copilot_model_not_created_for_default" {
}
}
run "model_validation_accepts_valid_models" {
run "copilot_model_accepts_custom_model" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "gpt-5"
copilot_model = "o3-pro"
}
assert {
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
error_message = "Model should be one of the valid options"
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"
}
}
+1 -5
View File
@@ -33,12 +33,8 @@ variable "github_token" {
variable "copilot_model" {
type = string
description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5."
description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used."
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" {
@@ -1,65 +0,0 @@
---
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.
@@ -1,13 +0,0 @@
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
@@ -1,190 +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."
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
}
@@ -1,271 +0,0 @@
# 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 -1
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.3.0"
version = "2.4.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
+14 -1
View File
@@ -53,6 +53,12 @@ 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."
@@ -220,6 +226,11 @@ resource "coder_env" "boundary_config" {
}
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) : ""
@@ -305,6 +316,8 @@ resource "coder_script" "agentapi_shutdown" {
}
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
@@ -341,5 +354,5 @@ resource "coder_app" "agentapi_cli" {
}
output "task_app_id" {
value = coder_app.agentapi_web.id
value = local.web_app ? coder_app.agentapi_web[0].id : ""
}
+126 -139
View File
@@ -1,152 +1,121 @@
---
display_name: Claude Code
description: Run the Claude Code agent in your workspace.
description: Install and configure the Claude Code CLI in your workspace.
icon: ../../../../.icons/claude.svg
verified: true
tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
tags: [agent, claude-code, ai, anthropic, ai-gateway]
---
# Claude Code
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.
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`).
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
```
> [!WARNING]
> **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.
> 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.
## Prerequisites
- 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)
Provide exactly one authentication method:
### Session Resumption Behavior
- **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`.
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`
## workdir
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
`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.
## Examples
### Usage with Agent Boundaries
### Standalone mode with a launcher app
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.
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.
```tf
locals {
claude_workdir = "/home/coder/project"
}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
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
}
```
> [!NOTE]
> 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.
> `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.
### Usage with AI Bridge
### Usage with AI Gateway
[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
[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 "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
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
}
```
When `enable_aibridge = true`, the module automatically sets:
When `enable_ai_gateway = true`, the module sets:
- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic`
- `CLAUDE_API_KEY` to the workspace owner's session token
- `ANTHROPIC_AUTH_TOKEN` to the workspace owner's Coder session token
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`.
Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic.
### 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.8.2"
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
}
```
> [!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.
### Advanced Configuration
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.
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
# OR
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
anthropic_api_key = "xxxx-xxxxx-xxxx"
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"
claude_code_version = "2.0.62" # Pin to a specific Claude CLI version.
model = "sonnet"
permission_mode = "plan"
# 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"
mcp = <<-EOF
{
@@ -166,6 +135,12 @@ 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:
>
@@ -180,41 +155,37 @@ module "claude-code" {
> }
> ```
>
> The `Content-Type` header doesn't matterboth `text/plain` and `application/json` work fine.
> The `Content-Type` header doesn't matter, both `text/plain` and `application/json` work fine.
### Standalone Mode
### Serialize a downstream `coder_script` after the install pipeline
Run and configure Claude Code as a standalone CLI in your workspace.
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>`:
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
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"
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"
}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
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
}
```
@@ -245,14 +216,12 @@ 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" {
@@ -273,7 +242,6 @@ 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" {
@@ -284,7 +252,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -341,7 +309,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.8.2"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -373,28 +341,47 @@ 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 `~/.claude-module` directory within your workspace for detailed information.
If you encounter any issues, check the log files in the `~/.coder-modules/coder/claude-code/logs` directory within your workspace for detailed information.
```bash
# Installation logs
cat ~/.claude-module/install.log
# Startup logs
cat ~/.claude-module/agentapi-start.log
cat ~/.coder-modules/coder/claude-code/logs/install.log
# Pre/post install script logs
cat ~/.claude-module/pre_install.log
cat ~/.claude-module/post_install.log
cat ~/.coder-modules/coder/claude-code/logs/pre_install.log
cat ~/.coder-modules/coder/claude-code/logs/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)
+316 -355
View File
@@ -6,15 +6,72 @@ import {
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../agentapi/test-util";
import dedent from "dedent";
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;
};
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
@@ -33,37 +90,96 @@ 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> }> => {
): Promise<{
id: string;
coderEnvVars: Record<string, string>;
scripts: ModuleScripts;
}> => {
const projectDir = "/home/coder/project";
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,
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",
});
if (!props?.skipClaudeMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/claude",
content: await loadTestFile(import.meta.dir, "claude-mock.sh"),
content: await Bun.file(
path.join(moduleDir, "testdata", "claude-mock.sh"),
).text(),
});
}
return { id, coderEnvVars };
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}`);
}
}
};
setDefaultTimeout(60 * 1000);
@@ -74,56 +190,50 @@ describe("claude-code", async () => {
});
test("happy-path", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
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");
});
test("install-claude-code-version", async () => {
const version_to_install = "1.0.40";
const { id, coderEnvVars } = await setup({
const version = "1.0.40";
const { id, coderEnvVars, scripts } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
claude_code_version: version_to_install,
claude_code_version: 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);
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain(version);
});
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 () => {
test("anthropic-api-key", async () => {
const apiKey = "test-api-key-123";
const { id } = await setup({
const { coderEnvVars } = await setup({
moduleVariables: {
claude_api_key: apiKey,
anthropic_api_key: apiKey,
},
});
await execModuleScript(id);
expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey);
});
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-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);
});
test("claude-mcp-config", async () => {
@@ -135,331 +245,67 @@ describe("claude-code", async () => {
},
},
});
const { id, coderEnvVars } = await setup({
const { id, coderEnvVars, scripts } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
mcp: mcpConfig,
},
});
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}`);
await runScripts(id, scripts, coderEnvVars);
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
expect(claudeConfig).toContain("test-cmd");
});
test("claude-model", async () => {
const model = "opus";
const { coderEnvVars } = await setup({
moduleVariables: {
model: model,
ai_prompt: "test prompt",
model,
},
});
// 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 } = await setup({
const { id, scripts } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
},
});
await execModuleScript(id);
await runScripts(id, scripts);
const preInstallLog = await readFileContainer(
id,
"/home/coder/.claude-module/pre_install.log",
"/home/coder/.coder-modules/coder/claude-code/logs/pre_install.log",
);
expect(preInstallLog).toContain("claude-pre-install-script");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.claude-module/post_install.log",
"/home/coder/.coder-modules/coder/claude-code/logs/post_install.log",
);
expect(postInstallLog).toContain("claude-post-install-script");
});
test("workdir-variable", async () => {
const workdir = "/home/coder/claude-test-folder";
const { id } = await setup({
skipClaudeMock: false,
const { id, scripts } = await setup({
moduleVariables: {
workdir,
},
});
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);
await runScripts(id, scripts);
// install.sh.tftpl echoes ARG_WORKDIR and creates the directory if missing.
const installLog = await readFileContainer(
id,
"/home/coder/.claude-module/install.log",
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
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");
expect(installLog).toContain(workdir);
});
test("mcp-config-remote-path", async () => {
@@ -467,43 +313,43 @@ EOF`,
const successUrl =
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
const { id, coderEnvVars } = await setup({
const { id, coderEnvVars, scripts } = await setup({
skipClaudeMock: true,
moduleVariables: {
install_claude_code: "true",
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
},
});
await execModuleScript(id, coderEnvVars);
await runScripts(id, scripts, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.claude-module/install.log",
"/home/coder/.coder-modules/coder/claude-code/logs/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 - no failure warning for it
// Second URL should succeed.
expect(installLog).not.toContain(
`Warning: Failed to fetch MCP configuration from '${successUrl}'`,
);
// Should contain the MCP server add command from successful fetch
// Should contain the MCP server add command from the successful fetch.
expect(installLog).toContain(
"Added stdio MCP server go-language-server to local config",
"Added stdio MCP server go-language-server to user config",
);
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to user config",
);
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to local config",
);
// Verify the MCP config was added to claude.json
// Verify the MCP config was added to .claude.json.
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
@@ -511,4 +357,119 @@ EOF`,
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();
});
});
+117 -286
View File
@@ -18,18 +18,6 @@ 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."
@@ -38,31 +26,8 @@ variable "icon" {
variable "workdir" {
type = string
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"
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
}
variable "pre_install_script" {
@@ -77,31 +42,6 @@ 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."
@@ -120,9 +60,9 @@ variable "disable_autoupdater" {
default = false
}
variable "claude_api_key" {
variable "anthropic_api_key" {
type = string
description = "The API key to use for the Claude Code server."
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var."
default = ""
}
@@ -132,78 +72,25 @@ 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 = "MCP JSON to be added to the claude code local scope"
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."
default = ""
}
variable "mcp_config_remote_path" {
type = list(string)
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)"
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON). Servers are added at Claude Code's user scope."
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 = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command"
description = "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`."
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."
@@ -215,83 +102,55 @@ variable "claude_binary_path" {
}
}
variable "install_via_npm" {
variable "enable_ai_gateway" {
type = bool
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"
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
default = false
validation {
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."
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."
}
validation {
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."
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."
}
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
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
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_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" "claude_api_key" {
count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
resource "coder_env" "anthropic_api_key" {
count = var.anthropic_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_API_KEY"
value = local.claude_api_key
name = "ANTHROPIC_API_KEY"
value = var.anthropic_api_key
}
# 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
}
resource "coder_env" "disable_autoupdater" {
@@ -310,123 +169,95 @@ resource "coder_env" "anthropic_model" {
}
resource "coder_env" "anthropic_base_url" {
count = var.enable_aibridge ? 1 : 0
count = var.enable_ai_gateway ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_BASE_URL"
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
}
locals {
# 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) : ""
# 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
},
)
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
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" "claude_code_enable_telemetry" {
count = var.telemetry.enabled ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_CODE_ENABLE_TELEMETRY"
value = "1"
}
output "task_app_id" {
value = module.agentapi.task_app_id
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
}
+103 -265
View File
@@ -20,30 +20,20 @@ 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"
claude_api_key = "test-api-key-123"
agent_id = "test-agent-456"
workdir = "/home/coder/workspace"
anthropic_api_key = "test-api-key-123"
}
assert {
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
error_message = "Claude API key value should match the input"
condition = coder_env.anthropic_api_key[0].value == "test-api-key-123"
error_message = "Anthropic API key value should match the input"
}
}
@@ -51,30 +41,12 @@ run "test_claude_code_with_custom_options" {
command = plan
variables {
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'"
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"
}
assert {
@@ -87,38 +59,13 @@ 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_and_tools" {
run "test_claude_code_with_mcp" {
command = plan
variables {
@@ -132,24 +79,12 @@ run "test_claude_code_with_mcp_and_tools" {
}
}
})
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" {
@@ -173,129 +108,13 @@ run "test_claude_code_with_scripts" {
}
}
run "test_claude_code_permission_mode_validation" {
run "test_ai_gateway_enabled" {
command = plan
variables {
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
agent_id = "test-agent-ai-gateway"
workdir = "/home/coder/ai-gateway"
enable_ai_gateway = true
}
override_data {
@@ -306,8 +125,8 @@ run "test_aibridge_enabled" {
}
assert {
condition = var.enable_aibridge == true
error_message = "AI Bridge should be enabled"
condition = var.enable_ai_gateway == true
error_message = "AI Gateway should be enabled"
}
assert {
@@ -317,102 +136,78 @@ run "test_aibridge_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 Bridge endpoint"
error_message = "ANTHROPIC_BASE_URL should point to AI Gateway endpoint"
}
assert {
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "CLAUDE_API_KEY environment variable should be set"
condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN"
error_message = "ANTHROPIC_AUTH_TOKEN environment variable should be set"
}
assert {
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"
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"
}
}
run "test_aibridge_validation_with_api_key" {
run "test_ai_gateway_validation_with_api_key" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_aibridge = true
claude_api_key = "test-api-key"
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_ai_gateway = true
anthropic_api_key = "test-api-key"
}
expect_failures = [
var.enable_aibridge,
var.enable_ai_gateway,
]
}
run "test_aibridge_validation_with_oauth_token" {
run "test_ai_gateway_validation_with_oauth_token" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_aibridge = true
claude_code_oauth_token = "test-oauth-token"
enable_ai_gateway = true
claude_code_oauth_token = "test-auth-token"
}
expect_failures = [
var.enable_aibridge,
var.enable_ai_gateway,
]
}
run "test_aibridge_disabled_with_api_key" {
run "test_ai_gateway_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent-no-aibridge"
workdir = "/home/coder/test"
enable_aibridge = false
claude_api_key = "test-api-key-xyz"
agent_id = "test-agent-no-ai-gateway"
workdir = "/home/coder/test"
enable_ai_gateway = false
anthropic_api_key = "test-api-key-xyz"
}
assert {
condition = var.enable_aibridge == false
error_message = "AI Bridge should be disabled"
condition = var.enable_ai_gateway == false
error_message = "AI Gateway should be disabled"
}
assert {
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"
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"
}
assert {
condition = length(coder_env.anthropic_base_url) == 0
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
error_message = "ANTHROPIC_BASE_URL should not be set when ai_gateway is disabled"
}
}
@@ -420,28 +215,71 @@ run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_aibridge = false
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_ai_gateway = false
}
assert {
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"
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_aibridge_no_override" {
run "test_api_key_count_with_ai_gateway_no_override" {
command = plan
variables {
agent_id = "test-agent-count"
workdir = "/home/coder/test"
enable_aibridge = true
agent_id = "test-agent-count"
workdir = "/home/coder/test"
enable_ai_gateway = true
}
assert {
condition = length(coder_env.claude_api_key) == 1
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
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"
}
}
@@ -1,240 +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:-"$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
@@ -0,0 +1,192 @@
#!/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
@@ -1,256 +0,0 @@
#!/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
+3 -6
View File
@@ -5,9 +5,6 @@ if [[ "$1" == "--version" ]]; then
exit 0
fi
set -e
while true; do
echo "$(date) - claude-mock"
sleep 15
done
# Mirror invocation for test assertions and exit cleanly.
echo "claude invoked with: $*"
exit 0
@@ -0,0 +1,101 @@
---
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.
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,216 @@
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] : [],
)
}
@@ -0,0 +1,628 @@
# 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"
}
}
+6 -4
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.4"
version = "1.1.5"
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.4"
version = "1.1.5"
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.4"
version = "1.1.5"
agent_id = coder_agent.main.id
database_path = ".config/filebrowser.db"
}
@@ -49,11 +49,13 @@ 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.4"
version = "1.1.5"
agent_id = coder_agent.main.id
agent_name = "main"
subdomain = false
@@ -102,4 +102,19 @@ 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);
});
+8 -1
View File
@@ -20,7 +20,7 @@ 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.)"
description = "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves."
default = null
}
@@ -102,6 +102,13 @@ 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" {
+31 -24
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.3.0"
version = "1.4.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.3.0"
version = "1.4.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.3.0"
version = "1.4.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.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -75,30 +75,37 @@ module "jetbrains" {
}
```
### Custom IDE Configuration
### 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`).
```tf
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/workspace/project"
folder = "/home/coder/project"
# Custom IDE metadata (display names and icons)
# Only build is required. Name and icon fall back to built-in defaults.
ide_config = {
"IU" = {
name = "IntelliJ IDEA"
icon = "/custom/icons/intellij.svg"
build = "251.26927.53"
}
"PY" = {
name = "PyCharm"
icon = "/custom/icons/pycharm.svg"
build = "251.23774.211"
}
"GO" = { build = "261.22158.291" }
"PY" = { build = "261.22158.340" }
# Add entries for other IDEs as needed.
}
options = ["GO", "PY"] # Must match the keys in ide_config.
}
```
@@ -108,7 +115,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/workspace/project"
@@ -128,7 +135,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.3.0"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -165,9 +172,9 @@ resource "coder_metadata" "container_info" {
### Version Resolution
- 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)
- **`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).
## Supported IDEs
@@ -1,53 +1,3 @@
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
@@ -259,15 +209,17 @@ run "output_empty_when_default_empty" {
}
}
run "output_single_ide_uses_fallback_build" {
run "uses_ide_config_when_set" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
# Force HTTP data source to fail to test fallback logic
releases_base_link = "https://coder.com"
options = ["GO"]
ide_config = {
"GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" }
}
}
assert {
@@ -281,30 +233,38 @@ run "output_single_ide_uses_fallback_build" {
}
assert {
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}'"
condition = output.ide_metadata["GO"].name == "GoLand Custom"
error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'"
}
assert {
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}'"
condition = output.ide_metadata["GO"].build == "999.99999.999"
error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'"
}
assert {
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}'"
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"
}
}
run "output_multiple_ides" {
run "uses_ide_config_for_multiple_ides" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["IU", "PY"]
# Force HTTP data source to fail to test fallback logic
releases_base_link = "https://coder.com"
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" }
}
}
assert {
@@ -318,15 +278,50 @@ run "output_multiple_ides" {
}
assert {
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}'"
condition = output.ide_metadata["PY"].name == "PyCharm"
error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'"
}
assert {
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}'"
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"
}
}
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
@@ -334,6 +329,10 @@ 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 {
@@ -351,3 +350,107 @@ 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,
]
}
+80 -47
View File
@@ -125,95 +125,128 @@ variable "download_base_link" {
}
data "http" "jetbrains_ide_versions" {
for_each = length(var.default) == 0 ? var.options : var.default
for_each = var.ide_config == null ? local.selected_ides : toset([])
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
}
variable "ide_config" {
description = <<-EOT
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.
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").
Example:
{
"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" },
"GO" = { build = "261.22158.291" },
"IU" = { build = "261.22158.277" },
}
EOT
type = map(object({
name = string
icon = string
build = string
name = optional(string)
icon = optional(string)
}))
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" }
}
default = null
validation {
condition = length(var.ide_config) > 0
condition = var.ide_config == null || 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 = alltrue([
condition = var.ide_config == null || alltrue([
for code in var.options : contains(keys(var.ide_config), code)
])
error_message = "The ide_config must be a superset of var.options."
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."
}
}
locals {
# Parse HTTP responses once with error handling for air-gapped environments
# 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.
parsed_responses = {
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
)
for code, response in data.http.jetbrains_ide_versions :
code => jsondecode(response.response_body)
}
# Filter the parsed response for the requested major version if not "latest"
filtered_releases = {
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]], []) :
for code, parsed in local.parsed_responses : code => [
for r in parsed[keys(parsed)[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 in length(var.default) == 0 ? var.options : var.default : code =>
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
for code, releases in local.filtered_releases :
code => length(releases) > 0 ? releases[0] : null
}
# Dynamically generate IDE configurations based on options with fallback to ide_config
# Dynamically generate IDE configurations based on selected IDEs
options_metadata = {
for code in length(var.default) == 0 ? var.options : var.default : code => {
icon = var.ide_config[code].icon
name = var.ide_config[code].name
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
identifier = code
key = code
# 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
# 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
# Store API data for potential future use
json_data = local.selected_releases[code]
# API response data, null when using ide_config.
json_data = var.ide_config != null ? null : 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" {
@@ -231,8 +264,8 @@ data "coder_parameter" "jetbrains_ides" {
dynamic "option" {
for_each = var.options
content {
icon = var.ide_config[option.value].icon
name = var.ide_config[option.value].name
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
value = option.value
}
}
@@ -16,7 +16,7 @@ The VSCode Desktop Core module is a building block for modules that need to expo
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.2"
version = "1.1.0"
agent_id = var.agent_id
@@ -3,6 +3,11 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
readFileContainer,
} from "~test";
// hardcoded coder_app name in main.tf
@@ -16,6 +21,7 @@ const defaultVariables = {
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
config_dir: "$HOME/.vscode",
};
describe("vscode-desktop-core", async () => {
@@ -134,4 +140,41 @@ describe("vscode-desktop-core", async () => {
expect(coder_app?.instances[0].attributes.group).toBe("web-app-group");
});
});
it("writes mcp_config.json when mcp_config variable provided", async () => {
const id = await runContainer("alpine");
try {
const mcp_config = JSON.stringify({
servers: { demo: { url: "http://localhost:1234" } },
});
const state = await runTerraformApply(import.meta.dir, {
...defaultVariables,
mcp_config,
});
const script = findResourceInstance(
state,
"coder_script",
"vscode-desktop-mcp",
).script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
const content = await readFileContainer(
id,
`${defaultVariables.config_dir.replace("$HOME", "/root")}/mcp_config.json`,
);
expect(content).toBe(mcp_config);
} finally {
await removeContainer(id);
}
}, 10000);
});
@@ -26,11 +26,22 @@ variable "open_recent" {
default = false
}
variable "mcp_config" {
type = map(any)
description = "MCP server configuration for the IDE. When set, writes mcp_config.json in var.config_dir."
default = null
}
variable "protocol" {
type = string
description = "The URI protocol the IDE."
}
variable "config_dir" {
type = string
description = "The path of the IDE's configuration folder."
}
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
@@ -85,21 +96,36 @@ resource "coder_app" "vscode-desktop" {
data.coder_workspace.me.access_url,
"&token=$SESSION_TOKEN",
])
}
/*
url = join("", [
"vscode://coder.coder-remote/open",
"?owner=${data.coder_workspace_owner.me.name}",
"&workspace=${data.coder_workspace.me.name}",
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=${data.coder_workspace.me.access_url}",
"&token=$SESSION_TOKEN",
])
*/
resource "coder_script" "vscode-desktop-mcp" {
agent_id = var.agent_id
count = var.mcp_config != null ? 1 : 0
icon = var.coder_app_icon
display_name = "${var.coder_app_display_name} MCP"
run_on_start = true
start_blocks_login = false
script = <<-EOT
#!/bin/sh
set -euo pipefail
IDE_CONFIG_FOLDER="${var.config_dir}"
IDE_MCP_CONFIG_PATH="$IDE_CONFIG_FOLDER/mcp_config.json"
mkdir -p "$IDE_CONFIG_FOLDER"
echo -n "${base64encode(jsonencode(var.mcp_config))}" | base64 -d > "$IDE_MCP_CONFIG_PATH"
chmod 600 "$IDE_MCP_CONFIG_PATH"
# Cursor/Windsurf use this config instead, no need for chmod as symlinks do not have modes
ln -s "$IDE_MCP_CONFIG_PATH" "$IDE_CONFIG_FOLDER/mcp.json"
EOT
}
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
}
@@ -0,0 +1,98 @@
---
display_name: Docker RStudio
description: Provision Docker containers with RStudio, code-server, and RMarkdown
icon: ../../../../.icons/rstudio.svg
verified: true
tags: [docker, rstudio, r, rmarkdown, code-server]
---
# R Development on Docker Containers
Provision Docker containers pre-configured for R development as [Coder workspaces](https://coder.com/docs/workspaces) with this template.
Each workspace comes with:
- **RStudio Server** — full-featured R IDE in the browser.
- **code-server** — VS Code in the browser for general editing.
- **RMarkdown** — author reproducible documents, reports, and presentations.
The workspace is based on the [rocker/rstudio](https://rocker-project.org/) image, which ships R and RStudio Server pre-installed.
## Prerequisites
### Infrastructure
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
sudo adduser coder docker
# Restart Coder server
sudo systemctl restart coder
# Test Docker
sudo -u coder docker ps
```
## Architecture
This template provisions the following resources:
- Docker image (built from `build/Dockerfile`, extending `rocker/rstudio` with system dependencies)
- Docker container (ephemeral — destroyed on workspace stop)
- Docker volume (persistent on `/home/rstudio`)
When the workspace restarts, tools and files outside `/home/rstudio` are not persisted. The R library path defaults to a subdirectory of the home folder, so installed packages (including RMarkdown) survive restarts.
> [!NOTE]
> This template is designed to be a starting point! Edit the Terraform to extend it for your use case.
## Customization
### Changing the R version
Set the `rstudio_version` variable to any valid [rocker/rstudio tag](https://hub.docker.com/r/rocker/rstudio/tags) (for example `4.4.2`, `4.3`, or `latest`).
### Installing additional R packages
R packages are pre-installed via the `build/Dockerfile` so they are available immediately when the workspace starts. To add more packages, add `install.packages()` calls to the Dockerfile:
```dockerfile
RUN R -e "install.packages(c('tidyverse', 'shiny'))"
```
The image is pre-configured to use [Posit Package Manager](https://packagemanager.posit.co/) which provides pre-compiled binary packages for fast installation. Packages installed at build time avoid long startup delays from compiling from source on every workspace start.
### Adding system dependencies
The `build/Dockerfile` extends the `rocker/rstudio` base image with system packages required by modules (e.g. `curl` for code-server, `cmake` for R package compilation). If you add modules that need additional system-level tools, add them to the `Dockerfile`:
```dockerfile
RUN apt-get update \
&& apt-get install -y \
curl \
cmake \
your-package-here \
&& rm -rf /var/lib/apt/lists/*
```
### Adding LaTeX for PDF rendering
RMarkdown can render PDF output when LaTeX is available. Add the following to the startup script to install TinyTeX:
```sh
R --quiet -e "if (!require('tinytex', quietly = TRUE)) { install.packages('tinytex', repos = 'https://cloud.r-project.org'); tinytex::install_tinytex() }"
```
@@ -0,0 +1,12 @@
ARG RSTUDIO_VERSION=4
FROM rocker/rstudio:${RSTUDIO_VERSION}
RUN apt-get update \
&& apt-get install -y \
curl \
cmake \
&& rm -rf /var/lib/apt/lists/*
RUN R -e "install.packages('rmarkdown')"
RUN echo "auth-minimum-user-id=0" >>/etc/rstudio/rserver.conf
@@ -0,0 +1,244 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
locals {
username = data.coder_workspace_owner.me.name
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
variable "rstudio_version" {
default = "4"
description = "The rocker/rstudio image tag to use (e.g. 4, 4.4, 4.4.2)"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us
# have an optional variable without having to set our own default.
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~ 2>/dev/null || true
touch ~/.init_done
fi
# Start RStudio Server. The rocker/rstudio image ships the
# server pre-installed. We disable authentication because
# the Coder proxy handles access control.
if command -v rserver > /dev/null 2>&1; then
sudo rserver \
--server-daemonize=0 \
--auth-none=1 \
--www-port=8787 \
--server-user=rstudio > /tmp/rserver.log 2>&1 &
elif [ -x /usr/lib/rstudio-server/bin/rserver ]; then
sudo /usr/lib/rstudio-server/bin/rserver \
--server-daemonize=0 \
--auth-none=1 \
--www-port=8787 \
--server-user=rstudio > /tmp/rserver.log 2>&1 &
fi
EOT
# These environment variables allow you to make Git commits
# right away after creating a workspace. They take precedence
# over configuration in ~/.gitconfig. Remove this block if
# you prefer to configure Git manually or via dotfiles.
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# Get load average scaled by number of cores.
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# RStudio Server served through the Coder proxy so users can
# open the full RStudio IDE directly from the dashboard.
resource "coder_app" "rstudio" {
agent_id = coder_agent.main.id
slug = "rstudio"
display_name = "RStudio"
url = "http://localhost:8787"
icon = "/icon/rstudio.svg"
subdomain = true
share = "owner"
order = 1
}
# See https://registry.coder.com/modules/coder/code-server
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
# This ensures that the latest non-breaking version of the
# module gets downloaded. You can also pin the module version
# to prevent breaking changes in production.
version = "~> 1.0"
agent_id = coder_agent.main.id
order = 2
folder = "/home/rstudio"
}
resource "docker_image" "main" {
name = "coder-${data.coder_workspace.me.id}-rstudio"
build {
context = "./build"
build_args = {
RSTUDIO_VERSION = var.rstudio_version
}
}
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in
# attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but
# can be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = docker_image.main.image_id
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: rstudio@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1.
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/rstudio"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+11
View File
@@ -0,0 +1,11 @@
---
display_name: Harsh Singh Panwar
bio: Open source contributor
github: Harsh9485
avatar: ./.images/avatar.png
status: community
---
# Harsh Singh Panwar
Community modules for Coder workspaces.
@@ -0,0 +1,80 @@
---
display_name: JetBrains Plugin Installer
description: Companion module for coder/jetbrains that automatically installs JetBrains Marketplace plugins.
icon: ../../../../.icons/jetbrains.svg
tags: [ide, jetbrains, plugins]
---
# JetBrains Plugin Installer
A companion module for
[coder/jetbrains](https://registry.coder.com/modules/jetbrains) that
automatically installs JetBrains Marketplace plugins into your workspace.
Use this alongside the core `coder/jetbrains` module — it handles plugin
installation while `coder/jetbrains` handles IDE setup and Toolbox
integration.
```tf
module "jetbrains_plugins" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/harsh9485/jetbrains-plugins/coder"
version = "0.1.0"
agent_id = coder_agent.main.id
jetbrains_plugins = {
"PY" = ["com.koxudaxi.pydantic", "com.intellij.kubernetes"]
}
}
```
## Prerequisites
- The [coder/jetbrains](https://registry.coder.com/modules/jetbrains)
module (or equivalent JetBrains Toolbox setup) must already be
configured in your template.
- `jq` must be available on `PATH`.
- Linux environment only.
## Finding Plugin IDs
Open the plugin page on the
[JetBrains Marketplace](https://plugins.jetbrains.com/). Scroll to
**Additional Information** and copy the **Plugin ID**.
## Usage
```tf
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["PY", "GO"]
}
module "jetbrains_plugins" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/harsh9485/jetbrains-plugins/coder"
version = "0.1.0"
agent_id = coder_agent.main.id
jetbrains_plugins = {
"PY" = ["com.koxudaxi.pydantic", "com.intellij.kubernetes"]
"GO" = ["org.jetbrains.plugins.go-template"]
}
}
```
The keys in `jetbrains_plugins` are IDE product codes (`PY`, `GO`, `IU`,
etc.) matching the codes used by the `coder/jetbrains` module. Each value
is a list of Marketplace plugin IDs to install for that IDE.
> [!IMPORTANT]
> After installing the IDE, restart the workspace. On the next start the
> module detects installed IDEs and automatically installs the configured
> plugins.
Some plugins may be disabled by default due to JetBrains security
defaults — you might need to enable them manually in the IDE.
@@ -0,0 +1,44 @@
run "no_script_when_plugins_empty" {
command = plan
variables {
agent_id = "foo"
jetbrains_plugins = {}
}
assert {
condition = length(resource.coder_script.install_jetbrains_plugins) == 0
error_message = "Expected no plugin install script when plugins map is empty"
}
}
run "script_created_when_plugins_provided" {
command = plan
variables {
agent_id = "foo"
jetbrains_plugins = {
"PY" = ["com.koxudaxi.pydantic", "com.intellij.kubernetes"]
}
}
assert {
condition = length(resource.coder_script.install_jetbrains_plugins) == 1
error_message = "Expected script to be created when plugins are provided"
}
}
run "rejects_invalid_product_code" {
command = plan
variables {
agent_id = "foo"
jetbrains_plugins = {
"INVALID" = ["com.example.plugin"]
}
}
expect_failures = [
var.jetbrains_plugins,
]
}
@@ -0,0 +1,59 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The resource ID of a Coder agent."
}
variable "jetbrains_plugins" {
type = map(list(string))
description = "Map of IDE product codes to plugin ID lists. Example: { IU = [\"com.foo\"], GO = [\"org.bar\"] }."
default = {}
validation {
condition = alltrue([
for code in keys(var.jetbrains_plugins) : contains(
["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"], code
)
])
error_message = "Keys must be valid JetBrains product codes: CL, GO, IU, PS, PY, RD, RM, RR, WS."
}
}
locals {
plugin_map_b64 = base64encode(jsonencode(var.jetbrains_plugins))
plugin_install_script = file("${path.module}/scripts/install_plugins.sh")
}
resource "coder_script" "install_jetbrains_plugins" {
count = length(var.jetbrains_plugins) > 0 ? 1 : 0
agent_id = var.agent_id
display_name = "Install JetBrains Plugins"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
CONFIG_DIR="$HOME/.config/JetBrains"
mkdir -p "$CONFIG_DIR"
echo -n "${local.plugin_map_b64}" | base64 -d > "$CONFIG_DIR/plugins.json"
chmod 600 "$CONFIG_DIR/plugins.json"
echo -n '${base64encode(local.plugin_install_script)}' | base64 -d > /tmp/install_plugins.sh
chmod +x /tmp/install_plugins.sh
/tmp/install_plugins.sh
EOT
}
@@ -0,0 +1,223 @@
#!/bin/bash
set -euo pipefail
LOGFILE="$HOME/.config/JetBrains/install_plugins.log"
TOOLBOX_BASE="$HOME/.local/share/JetBrains/Toolbox/apps"
PLUGIN_MAP_FILE="$HOME/.config/JetBrains/plugins.json"
PLUGIN_ALREADY_INSTALLED_MAP="$HOME/.config/JetBrains"
# Verify jq is available
if ! command -v jq > /dev/null 2>&1; then
echo "Error: 'jq' is required but not installed. Please install it manually." >&2
exit 1
fi
mkdir -p "$(dirname "$LOGFILE")"
exec > >(tee -a "$LOGFILE") 2>&1
log() {
printf '%s %s\n' "$(date --iso-8601=seconds)" "$*"
}
# -------- Read plugin JSON --------
get_enabled_codes() {
jq -r 'keys[]' "$PLUGIN_MAP_FILE"
}
get_plugins_for_code() {
jq -r --arg CODE "$1" '.[$CODE][]?' "$PLUGIN_MAP_FILE" 2> /dev/null || true
}
# Returns only plugins that are NOT already installed
check_plugins_installed() {
local code="$1"
shift
local plugins=("$@")
local installed_file="$PLUGIN_ALREADY_INSTALLED_MAP/${code}_installed.json"
# If no installed file exists, all plugins need to be installed
if [ ! -f "$installed_file" ]; then
printf '%s\n' "${plugins[@]}"
return 0
fi
installed_plugins=$(jq -r '.[]?' "$installed_file" 2> /dev/null)
for plugin in "${plugins[@]}"; do
if ! echo "$installed_plugins" | grep -Fxq "$plugin"; then
echo "$plugin"
fi
done
return 0
}
# -------- Product code mapping --------
map_folder_to_code() {
case "$1" in
*pycharm*) echo "PY" ;;
*idea*) echo "IU" ;;
*webstorm*) echo "WS" ;;
*goland*) echo "GO" ;;
*clion*) echo "CL" ;;
*phpstorm*) echo "PS" ;;
*rider*) echo "RD" ;;
*rubymine*) echo "RM" ;;
*rustrover*) echo "RR" ;;
*) echo "" ;;
esac
}
# -------- CLI launcher names --------
launcher_for_code() {
case "$1" in
PY) echo "pycharm" ;;
IU) echo "idea" ;;
WS) echo "webstorm" ;;
GO) echo "goland" ;;
CL) echo "clion" ;;
PS) echo "phpstorm" ;;
RD) echo "rider" ;;
RM) echo "rubymine" ;;
RR) echo "rustrover" ;;
*) return 1 ;;
esac
}
find_cli_launcher() {
local exe
exe="$(launcher_for_code "$1")" || return 1
# Look for the newest version directory
local latest_version
latest_version=$(find "$2" -maxdepth 2 -type d -name "ch-*" 2> /dev/null | sort -V | tail -1)
if [ -n "$latest_version" ] && [ -f "$latest_version/bin/$exe" ]; then
echo "$latest_version/bin/$exe"
elif [ -f "$2/bin/$exe" ]; then
echo "$2/bin/$exe"
else
return 1
fi
}
# Marks a plugin as installed by adding it to the installed plugins JSON file
mark_plugins_installed() {
local code="$1"
local plugin="$2"
local installed_file="$PLUGIN_ALREADY_INSTALLED_MAP/${code}_installed.json"
mkdir -p "$PLUGIN_ALREADY_INSTALLED_MAP"
# Create file with empty array if it doesn't exist
if [ ! -f "$installed_file" ]; then
echo '[]' > "$installed_file" || {
log "Error: Failed to create $installed_file"
return 1
}
fi
jq --arg PLUGIN "$plugin" '. += [$PLUGIN]' "$installed_file" > "${installed_file}.tmp" 2> /dev/null \
&& mv "${installed_file}.tmp" "$installed_file" || {
log "Error: Failed to update $installed_file with plugin $plugin"
rm -f "${installed_file}.tmp"
return 1
}
log "Marked plugin as installed: $plugin"
return 0
}
install_plugin() {
log "Installing plugin: $2"
if "$1" installPlugins "$2"; then
log "Successfully installed plugin: $2"
return 0
else
log "Failed to install plugin: $2"
return 1
fi
}
# -------- Main --------
log "Plugin installer started"
if [ ! -f "$PLUGIN_MAP_FILE" ]; then
log "No plugins.json found. Exiting."
exit 0
fi
if [ ! -d "$TOOLBOX_BASE" ]; then
log "Toolbox directory not found. Exiting."
exit 0
fi
# Load list of IDE codes user actually needs
mapfile -t pending_codes < <(get_enabled_codes)
if [ ${#pending_codes[@]} -eq 0 ]; then
log "No plugin entries found. Exiting."
exit 0
fi
log "Waiting for IDE installation. Pending codes: ${pending_codes[*]}"
# Loop until all plugins installed
for product_dir in "$TOOLBOX_BASE"/*; do
[ -d "$product_dir" ] || continue
product_name="$(basename "$product_dir")"
code="$(map_folder_to_code "$product_name")"
# Only process codes user requested
if [[ ! " ${pending_codes[*]} " =~ " $code " ]]; then
continue
fi
# Store plugins as array for consistency
mapfile -t plugins_list < <(get_plugins_for_code "$code")
if [ ${#plugins_list[@]} -eq 0 ]; then
log "No plugins for $code"
continue
fi
# Get only plugins that are not already installed
mapfile -t new_plugins < <(check_plugins_installed "$code" "${plugins_list[@]}")
if [ ${#new_plugins[@]} -eq 0 ]; then
log "All plugins for $code are already installed"
# Remove code from pending list since all plugins are installed
tmp=()
for c in "${pending_codes[@]}"; do
[ "$c" != "$code" ] && tmp+=("$c")
done
pending_codes=("${tmp[@]}")
continue
fi
cli_launcher_path="$(find_cli_launcher "$code" "$product_dir")" || continue
log "Detected IDE $code at $product_dir"
log "Plugins to install for $code: ${#new_plugins[@]} plugin(s)"
# Install only the plugins that are not yet installed
for plugin in "${new_plugins[@]}"; do
if install_plugin "$cli_launcher_path" "$plugin"; then
# Mark plugin as installed after successful installation
mark_plugins_installed "$code" "$plugin"
fi
done
# remove code from pending list after success
tmp=()
for c in "${pending_codes[@]}"; do
[ "$c" != "$code" ] && tmp+=("$c")
done
pending_codes=("${tmp[@]}")
log "Finished $code. Remaining: ${pending_codes[*]:-none}"
done
if [ ${#pending_codes[@]} -gt 0 ]; then
log "These IDEs not found: ${pending_codes[*]}"
fi
log "Exiting."
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+11
View File
@@ -0,0 +1,11 @@
---
display_name: "Jörg Klein"
bio: "Data Scientists"
github: "joergklein"
avatar: "./.images/avatar.jpg"
status: "community"
---
# Jörg Klein
Data Scientists
@@ -0,0 +1,90 @@
---
display_name: Docker TeX Live
description: Provision Docker containers with TeX Live, code-server
icon: ../../../../.icons/texlive.svg
tags: [docker, texlive]
---
# TeX Live Development on Docker Containers
Provision Docker containers pre-configured for TeX development as [Coder workspaces](https://coder.com/docs/workspaces) with this template.
Each workspace comes with:
- **TeX Live** — TeX Live is a comprehensive, cross-platform distribution for TeX and LaTeX systems that provides all necessary programs, macro packages, and fonts for professional typesetting.
- **code-server** — VS Code in the browser for general editing.
The workspace is based on the [TeX Live](https://www.tug.org/texlive) image. It provides nearly all packages from the [Comprehensive TeX Archive Network (CTAN)](https://www.ctan.org), although some non-free packages may be restricted.
## Prerequisites
### Infrastructure
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```bash
# Add coder user to Docker group
sudo adduser coder docker
# Restart Coder server
sudo systemctl restart coder
# Test Docker
sudo -u coder docker ps
```
## Architecture
This template provisions the following resources:
- Docker image (built from `build/Dockerfile`, extending `registry.gitlab.com/islandoftex/images/texlive` with system dependencies)
- Docker container (ephemeral — destroyed on workspace stop)
- Docker volume (persistent on `/home/texlive`)
When the workspace restarts, tools and files outside `/home/texlive` are not persisted.
> [!NOTE]
> This template is designed to be a starting point! Edit the Terraform to extend it for your use case.
## Customization
The continuous integration is scheduled to rebuild all Docker images weekly. Hence, pulling the latest image will provide you with an at most one week old snapshot of TeX Live including all packages. You can manually update within the container by running `tlmgr update --self --all`.
Each of the weekly builds is tagged with `TL{RELEASE}-{YEAR}-{MONTH}-{DAY}-{HOUR}-{MINUTE}` apart from being latest for one week. If you want to have reproducible builds or happen to find a regression in a later image you can still revert to a date that worked, e.g. `TL2019-2019-08-01-08-14 or latest`.
- [Container Registry TeX Live](https://gitlab.com/islandoftex/images/texlive/container_registry)
- [Dockerhub TeX Live](https://hub.docker.com/r/texlive/texlive)
### Installing additional TeX packages
If you want to update packages from CTAN after installation, see these [examples of using tlmgr](https://tug.org/texlive/doc/tlmgr.html#EXAMPLES). This is not required, or even necessarily recommended; it's up to you to decide if it makes sense to get continuing updates in your particular situation.
Typically the main binaries are not updated in TeX Live between major releases. If you want to get updates for LuaTeX and other packages and programs that aren't officially released yet, they may be available in the [TLContrib repository](http://contrib.texlive.info), or you may need to [compile the sources](https://tug.org/texlive/svn) yourself.
### Adding system dependencies
The `build/Dockerfile` extends the `registry.gitlab.com/islandoftex/images/texlive` base image with system packages required by modules (e.g. `curl` for code-server). If you add modules that need additional system-level tools, add them to the `Dockerfile`:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
inkscape \
unzip \
vim \
wget \
your-package-here \
&& rm -rf /var/lib/apt/lists/*
```
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
ARG TEXLIVE_VERSION=latest
FROM registry.gitlab.com/islandoftex/images/texlive:${TEXLIVE_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
inkscape \
unzip \
vim \
wget \
&& rm -rf /var/lib/apt/lists/*
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN useradd -m -s /bin/bash texlive || true
ENV HOME=/home/texlive
ENV TEXMFCNF=/home/texlive/texmf/web2c:/usr/local/texlive/2026/texmf-dist/web2c
RUN mkdir -p /home/texlive/texmf/web2c \
&& echo "save_size = 300000" >/home/texlive/texmf/web2c/texmf.cnf \
&& chown -R texlive:texlive /home/texlive
USER texlive
WORKDIR /home/texlive
@@ -0,0 +1,200 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
# -------------------------
# Variables
# -------------------------
variable "docker_socket" {
type = string
default = ""
}
variable "texlive_version" {
type = string
default = "latest"
}
# -------------------------
# Provider
# -------------------------
provider "docker" {
host = var.docker_socket != "" ? var.docker_socket : null
}
# -------------------------
# Coder data
# -------------------------
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# -------------------------
# Locals
# -------------------------
locals {
username = try(data.coder_workspace_owner.me.name, "unknown")
start_count = try(data.coder_workspace.me.start_count, 0)
build_context_hash = sha1(join("", [
for f in fileset("${path.module}/build", "**") :
try(filesha1("${path.module}/build/${f}"), "")
]))
}
# -------------------------
# Coder Agent
# -------------------------
resource "coder_agent" "main" {
arch = try(data.coder_provisioner.me.arch, "x86_64")
os = "linux"
startup_script = <<-EOT
set -e
touch ~/.init_done
EOT
env = {
HOME = "/home/texlive"
USER = "texlive"
LANG = "C.UTF-8"
LC_ALL = "C.UTF-8"
GIT_AUTHOR_NAME = coalesce(try(data.coder_workspace_owner.me.full_name, ""), local.username)
GIT_AUTHOR_EMAIL = try(data.coder_workspace_owner.me.email, "unknown@example.com")
}
metadata {
display_name = "CPU"
key = "cpu"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM"
key = "ram"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Disk"
key = "disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
}
# -------------------------
# Code Server
# -------------------------
module "code-server" {
count = local.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
folder = "/home/texlive"
}
# -------------------------
# Docker Image
# -------------------------
resource "docker_image" "texlive" {
name = "coder-${data.coder_workspace.me.id}-texlive"
build {
context = "${path.module}/build"
dockerfile = "Dockerfile"
build_args = {
TEXLIVE_VERSION = var.texlive_version
}
}
keep_locally = false
triggers = {
dir_hash = local.build_context_hash
texlive_version = var.texlive_version
}
}
# -------------------------
# Volume (correct docker provider syntax)
# -------------------------
resource "docker_volume" "home_volume" {
name = "coder-${try(data.coder_workspace.me.id, 0)}-home"
labels {
label = "coder.owner"
value = local.username
}
labels {
label = "coder.workspace_id"
value = try(data.coder_workspace.me.id, "0")
}
labels {
label = "coder.workspace_name"
value = try(data.coder_workspace.me.name, "workspace")
}
lifecycle {
ignore_changes = all
}
}
# -------------------------
# Container
# -------------------------
resource "docker_container" "workspace" {
count = local.start_count
image = docker_image.texlive.image_id
name = "coder-${local.username}-${lower(try(data.coder_workspace.me.name, "workspace"))}"
hostname = try(data.coder_workspace.me.name, "workspace")
entrypoint = [
"sh",
"-c",
coder_agent.main.init_script
]
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}"
]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/texlive"
volume_name = docker_volume.home_volume.name
read_only = false
}
labels {
label = "coder.owner"
value = local.username
}
labels {
label = "coder.workspace_id"
value = try(data.coder_workspace.me.id, "0")
}
}