Compare commits

..

15 Commits

Author SHA1 Message Date
Alex Rosenfeld 50637980bd feat(coder/modules/code-server): add support for workspace query param (#912)
## Description

Resolves https://github.com/coder/registry/issues/911

## 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/modules/code-server`  
**New version:** `v1.5.0`
**Breaking change:** [ ] Yes [x] No

## Related Issues

https://github.com/coder/registry/issues/911

---------

Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-06-06 19:04:25 +05:30
35C4n0r 5be6a38f23 feat: migrate codex installation to use official install script (#908)
## Description

* migrate codex installation to use official install script
* remove npm installation

## Type of Change

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

## Module Information

**Path:** `registry/coder-labs/modules/codex` <br>**New version:**
`v5.1.0` <br>**Breaking change:** \[x\] Yes \[ \] No

## Testing & Validation

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

## Related Issues

Closes: coder/registry#905

---------

Co-authored-by: Atif Ali <atif@coder.com>
2026-06-05 21:56:23 +05:30
Ben Potter f80f36d674 feat: remove agent metadata for incus-nixos 2026-06-04 09:53:49 -05:00
Ben Potter d1d2d5b433 feat(bpmct/templates): add incus-vm and incus-nixos templates (#910)
## Description

Adds two new templates under `registry/bpmct` for running workspaces on
[Incus](https://linuxcontainers.org/incus/) (the open-source LXD fork).

### `incus-vm`

Provisions a full KVM virtual machine on a remote Incus host using
cloud-init. This is a VM variant of the existing `coder/incus` system
container template.

Key differences from `coder/incus`:
- Launches a **virtual machine** (`type = "virtual-machine"`) rather
than a system container
- Supports **remote Incus hosts** via `incus remote add` — the Coder
provisioner does not need to be co-located with the Incus host
- `arch` parameter (`amd64` / `arm64`) threads through the agent, image
fetch, and Incus architecture hint — so the same template works on both
x86-64 and aarch64 hosts
- Token rotation handled on every workspace start via a `null_resource`
provisioner (necessary since cloud-init only runs once on first boot)

### `incus-nixos`

Provisions a NixOS virtual machine on a remote Incus host. NixOS
requires a different provisioning approach from the cloud-init-based
`incus-vm` template.

Key design points:
- Uses Incus's official NixOS images (`nixos/<channel>`)
- Provisions entirely via `nixos-rebuild switch` — no cloud-init
- Writes `/etc/nixos/coder.nix` and `/etc/nixos/configuration.nix` via
`incus exec` on first boot
- Idempotent: subsequent starts skip the rebuild and just refresh the
agent token
- `nixos_channel` is a Terraform `variable` (admin sets at push time);
no user-facing image picker since the whole point of the template is
NixOS

## Type of Change

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

## Template Information

**Paths:**
- `registry/bpmct/templates/incus-vm`
- `registry/bpmct/templates/incus-nixos`

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally (both templates deployed and workspaces
verified healthy on aarch64 Raspberry Pi provisioners)

## Related Issues

None

## Video

https://www.loom.com/share/f009972a51af452dbbe9eda9f9760d3e
2026-06-04 09:44:37 -05:00
dependabot[bot] 358ca6804b chore(deps): bump crate-ci/typos from 1.46.3 to 1.47.0 in the github-actions group (#906)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 13:19:02 +05:00
35C4n0r 94203b2c8b fix(coder/modules/dotfiles): allow tilde in DOTFILES_URI shell validation (#904)
Fixes https://github.com/coder/registry/issues/762

## Problem

The shell-side URI validation regex in `run.sh` did not include `~` in
the allowed character set. URLs containing tilde paths (common in
Bitbucket Server personal repositories, e.g.
`ssh://git@bitbucket.example.org:7999/~user/repo.git`) were rejected at
runtime with `ERROR: DOTFILES_URI contains invalid characters`.

The Terraform-side validations in `main.tf` already allowed `~`, so the
inconsistency only surfaced when the script actually ran in a workspace.

## Changes

- **`run.sh`**: Added `~` to the character class in the shell validation
regex, making it consistent with the three Terraform regex patterns in
`main.tf`.
- **`main.test.ts`**: The "accepts valid git URL formats" test now also
executes the rendered shell script and asserts that the shell-side
validation does not reject any of the valid URLs. This closes the
coverage gap that let the Terraform/shell inconsistency go undetected.

> 🤖 Generated by Coder Agents

---------

Authored-by: Jay Kumar <jay.kumar@coder.com>
2026-06-01 17:48:19 +05:30
35C4n0r f5d7895275 docs(coder/modules/git-clone): fix placeholder in troubleshooting path (#902)
## Description
Fix the troubleshooting section placeholder from `<instance>` to
`<folder_name>` to match the actual path component used in `module_dir`
(`${local.folder_name}`).

## Type of Change
- [x] Documentation

## Module Information
**Path:** `registry/coder/modules/git-clone`

Follow-up to #893.

> 🤖 Generated with [Coder Agents](https://coder.com)

Co-authored-by: Jay Kumar <jay.kumar@coder.com>
2026-05-27 21:27:00 +05:30
35C4n0r 76c7371ed9 feat(coder/modules/git-clone): add support for extra_args and drop depth (#893)
## Description
- add support for extra_args and drop depth

## 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/modules/git-clone`  
**New version:** `v2.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 #74

---------

Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: Jay Kumar <jay.kumar@coder.com>
2026-05-27 02:48:30 +00:00
DevCats 139fadb975 feat(registry/coder/skills): register modules and templates skills (#901)
## Summary

Adds two new catalogue entries to `registry/coder/skills/README.md`:

- `coder/modules` with `coder-modules.svg`
- `coder/templates` with `coder-templates.svg`

Both pull from `coder/skills@main` alongside the existing `setup` skill.
Tag sets are scoped so the registry-server filter facets pick them up
(`[coder, terraform, modules]` and `[coder, terraform, templates]`).

## Verified locally

```
go run ./cmd/readmevalidation
processing skills README files  num_files=1
processed all skills README files  num_files=1
```

No other validator output changed (23 contributor profiles, 79 modules,
33 templates still parse cleanly).

## Source repo content

The skill content (SKILL.md plus per-skill metadata) lives in
coder/skills#2. Until that PR merges, this catalogue change is
effectively a no-op: the registry-server build pipeline iterates over
skills it discovers in the source repo, and looks up catalogue overrides
per skill. Catalogue entries for skills that do not yet exist in the
source repo are silently ignored.

That means these two PRs can land in either order without breaking
anything. Both have to be merged before the new skills appear on
registry.coder.com.

## Related

- coder/skills#2 (source repo content for `modules` and `templates`)
- coder/registry-server#442 (build pipeline, API, MCP, frontend)
- coder/registry#884 (catalogue format)

This PR was created with help from Coder Agents.
2026-05-26 14:55:33 -05:00
dependabot[bot] e873e43d6b chore(deps): bump the github-actions group with 3 updates (#900)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 13:58:04 +00:00
DevCats 20051c7089 feat: add skills as namespace-level catalogue entries with external source repos (#884)
## Summary

Adds skills as a catalogue resource type in the registry. Each namespace
declares its skill source repos and per-skill presentation metadata in
`registry/<namespace>/skills/README.md`. The registry-server build
pipeline clones source repos, auto-discovers skills, and serves them
with the metadata defined here.

## Catalogue format

The skills README uses structured YAML frontmatter with nested per-skill
metadata:

```yaml
---
icon: ../../../.icons/coder.svg
sources:
  - repo: coder/skills@main
    skills:
      setup:
        display_name: Setup & Configuration
        icon: ../../../.icons/coder.svg
        tags: [coder, deployment, configuration]
---
```

- `icon` (top-level): default icon for skills without a per-skill
override
- `sources[].repo`: GitHub repo to clone (`owner/repo@ref`)
- `sources[].skills`: per-skill overrides for `display_name`,
`description`, `icon`, and `tags`
- Multiple repos per namespace are supported
- Skills not listed in the `skills` map are still discovered with
default metadata
- `name` and `description` always come from the source repo's SKILL.md
unless overridden

## Changes

- `registry/coder/skills/README.md`: Coder namespace pointing to
`coder/skills@main` with per-skill metadata
- `registry/DevelopmentCats/skills/README.md`: Test namespace pointing
to `DevelopmentCats/skills@main` (remove before merge)
- `registry/DevelopmentCats/README.md` + `.images/avatar.svg`: Test
namespace profile (remove before merge)
- `.github/workflows/deploy-registry.yaml`: Added
`registry/**/skills/**` path trigger
- `.github/workflows/release.yml`: Skill/module path detection in tag
extraction
- `.github/workflows/version-bump.yaml`: Added `registry/**/skills/**`
path trigger
- `cmd/readmevalidation/repostructure.go`: Added `skills` to supported
namespace directories

## Related

-
[registry-server#442](https://github.com/coder/registry-server/pull/442):
Build pipeline, API, MCP, frontend, and well-known discovery for skills
- [coder/skills](https://github.com/coder/skills): Coder's official
skills source repo
- [Problem
Document](https://www.notion.so/35dd579be59281a4b657d02174667e4f):
Skills as First-Class Registry Catalogue Items

> 🤖 This PR was updated with the help of Coder Agents.
2026-05-22 12:20:55 -05:00
Ben Potter 1601ab3e8b feat(.icons): add Lucide SVG icons for skill cards (#880) 2026-05-20 13:18:52 +00:00
dependabot[bot] f9802456ce chore(deps): bump the github-actions group across 1 directory with 3 updates (#892)
Bumps the github-actions group with 3 updates in the / directory:
[coder/coder](https://github.com/coder/coder),
[crate-ci/typos](https://github.com/crate-ci/typos) and
[zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).

Updates `coder/coder` from 2.32.0 to 2.33.3
<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.33.3</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>Bug fixes</h3>
<ul>
<li>Upgrade Go toolchain from 1.25.9 to 1.25.10 (<a
href="https://redirect.github.com/coder/coder/issues/25230">#25230</a>,
e5a96f3608)</li>
<li>Cherry-pick go-git v5.19.0 (CVE-2026-45022) (<a
href="https://redirect.github.com/coder/coder/issues/25229">#25229</a>,
4e4e23539e)</li>
<li>Dashboard: Show Organizations in admin dropdown for single-org OSS
deployments (<a
href="https://redirect.github.com/coder/coder/issues/25175">#25175</a>,
bbca430b4c)</li>
<li>fix(scripts/ironbank): update base image to UBI9 and remove urllib3
(CVE-2026-44431) (<a
href="https://redirect.github.com/coder/coder/issues/25247">#25247</a>,
818fc72802)</li>
<li>Server: Harden Azure identity certificate fetch (cherry-pick v2.33)
(<a
href="https://redirect.github.com/coder/coder/issues/25276">#25276</a>,
844c1e0467)</li>
<li>Verify PKCS7 signature on Azure instance identity tokens (2.33
cherry-pick) (<a
href="https://redirect.github.com/coder/coder/issues/25302">#25302</a>,
2b778f292c)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.33.2...v2.33.3"><code>v2.33.2...v2.33.3</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:2.33.3</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>
<h2>v2.33.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>Bug fixes</h3>
<ul>
<li>Backport 11 Coder Agents docs PRs to release/2.33 (<a
href="https://redirect.github.com/coder/coder/issues/25047">#25047</a>,
d622e86fa0)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.33.1...v2.33.2"><code>v2.33.1...v2.33.2</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:2.33.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>
<h2>v2.33.1</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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/coder/coder/commit/2b778f292c2ddf8ac261683d0d5d8a18da1512f6"><code>2b778f2</code></a>
fix: verify PKCS7 signature on Azure instance identity tokens (2.33
cherry-pi...</li>
<li><a
href="https://github.com/coder/coder/commit/844c1e0467f3124691523dbc0717c88539ea2fb4"><code>844c1e0</code></a>
fix(coderd): harden Azure identity certificate fetch (cherry-pick v2.33)
(<a
href="https://redirect.github.com/coder/coder/issues/25">#25</a>...</li>
<li><a
href="https://github.com/coder/coder/commit/818fc72802e72e30230ec8b13bd8e47d01454764"><code>818fc72</code></a>
fix(scripts/ironbank): update base image to UBI9 and remove urllib3
(CVE-2026...</li>
<li><a
href="https://github.com/coder/coder/commit/bbca430b4cbfd8434113c595c62ea1b613c1b38c"><code>bbca430</code></a>
fix(site): show Organizations in admin dropdown for single-org OSS
deployment...</li>
<li><a
href="https://github.com/coder/coder/commit/4e4e23539e78c95b13e50ab66e4ccaeb5241a5fd"><code>4e4e235</code></a>
fix: cherry-pick go-git v5.19.0 (CVE-2026-45022) (<a
href="https://redirect.github.com/coder/coder/issues/25229">#25229</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/e5a96f3608ee45dfdaba3e6205fe6cd15e3c32d1"><code>e5a96f3</code></a>
fix: upgrade Go toolchain from 1.25.9 to 1.25.10 (<a
href="https://redirect.github.com/coder/coder/issues/25230">#25230</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/d622e86fa0b3a9c5d3014480e926217ebed20a43"><code>d622e86</code></a>
fix: backport 11 Coder Agents docs PRs to release/2.33 (<a
href="https://redirect.github.com/coder/coder/issues/25047">#25047</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/3e34ba7bf029394b642bced1428af2d94a99f55a"><code>3e34ba7</code></a>
chore: remove agents experiment flag and mark feature as beta (<a
href="https://redirect.github.com/coder/coder/issues/24432">#24432</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/25003">#25003</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/f009c17217e6bad9a61ba511d23735bc1ce94da0"><code>f009c17</code></a>
fix(coderd): cut DB fan-out on agent instance-identity auth (backport <a
href="https://redirect.github.com/coder/coder/issues/24973">#24973</a>)...</li>
<li><a
href="https://github.com/coder/coder/commit/17635dde5c99612b4aaf80970d49a116ed3fa29c"><code>17635dd</code></a>
chore: include pgcoordinator schema changes in 2.33 (<a
href="https://redirect.github.com/coder/coder/issues/24931">#24931</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/coder/coder/compare/34584e909bbe6f501fb2cbdc994325b4d3f9e2ef...2b778f292c2ddf8ac261683d0d5d8a18da1512f6">compare
view</a></li>
</ul>
</details>
<br />

Updates `crate-ci/typos` from 1.45.1 to 1.46.2
<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.46.2</h2>
<h2>[1.46.2] - 2026-05-16</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct to <code>criterias</code></li>
<li>Don't correct to <code>replaceables</code></li>
</ul>
<h2>v1.46.1</h2>
<h2>[1.46.1] - 2026-05-08</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct to <code>confidentials</code></li>
</ul>
<h2>v1.46.0</h2>
<h2>[1.46.0] - 2026-04-30</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1531">April
2026</a> changes</li>
</ul>
<h2>v1.45.2</h2>
<h2>[1.45.2] - 2026-04-27</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore ssh ed25519 public keys</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.46.2] - 2026-05-16</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct to <code>criterias</code></li>
<li>Don't correct to <code>replaceables</code></li>
</ul>
<h2>[1.46.1] - 2026-05-08</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct to <code>confidentials</code></li>
</ul>
<h2>[1.46.0] - 2026-04-30</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1531">April
2026</a> changes</li>
</ul>
<h2>[1.45.2] - 2026-04-27</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore ssh ed25519 public keys</li>
</ul>
<h2>[1.45.1] - 2026-04-13</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Use a temp dir for caching</li>
</ul>
<h2>[1.45.0] - 2026-04-01</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1509">March
2026</a> changes</li>
</ul>
<h2>[1.44.0] - 2026-02-27</h2>
<h3>Features</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/aca895bf05aec0cb7dffa6f94495e923224d9f17"><code>aca895b</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/4dbdd7509d345c6a2abf73bb722a2ae0126eec72"><code>4dbdd75</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/3da287673172dece00f174b38faa763e7cb294dc"><code>3da2876</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1556">#1556</a>
from epage/replaceable</li>
<li><a
href="https://github.com/crate-ci/typos/commit/8918680477461d6cb133f4565eaa70f8237e27ae"><code>8918680</code></a>
fix(dict): Don't correct to replaceables</li>
<li><a
href="https://github.com/crate-ci/typos/commit/57d5422e87c3d28c9b9a61785ac5e8e0fcaae205"><code>57d5422</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1555">#1555</a>
from epage/criteria</li>
<li><a
href="https://github.com/crate-ci/typos/commit/f54668abd732ae8ade4a7cd837c9d3c798361ca6"><code>f54668a</code></a>
fix(dict): Don't correct to criterias</li>
<li><a
href="https://github.com/crate-ci/typos/commit/5374cbf686e897b15713110e233094e2874de7ef"><code>5374cbf</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/52448f5ecf85209e284e8db1c77dd4885885068a"><code>52448f5</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/030c719ff1afe2ff0f85b84d4f99b7a9a57c3b29"><code>030c719</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1552">#1552</a>
from epage/fixes</li>
<li><a
href="https://github.com/crate-ci/typos/commit/7a688c7c08aaa1aa67686848eac4cdd7cb3bb1d2"><code>7a688c7</code></a>
fix(dict): Confidentials isn't valid</li>
<li>Additional commits viewable in <a
href="https://github.com/crate-ci/typos/compare/cf5f1c29a8ac336af8568821ec41919923b05a83...aca895bf05aec0cb7dffa6f94495e923224d9f17">compare
view</a></li>
</ul>
</details>
<br />

Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/zizmorcore/zizmor-action/releases">zizmorcore/zizmor-action's
releases</a>.</em></p>
<blockquote>
<h2>v0.5.6</h2>
<ul>
<li>1.25.2 is now available via the action</li>
<li>1.25.2 is now the default version of zizmor used by the action</li>
</ul>
<h2>v0.5.5</h2>
<p>This is a no-op release.</p>
<h2>v0.5.4</h2>
<ul>
<li>1.25.0 is now available via the action</li>
<li>1.25.0 is now the default version of zizmor used by the action</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5f14fd08f7cf1cb1609c1e344975f152c7ee938d"><code>5f14fd0</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/114">#114</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/a16621b09c6db4281f81a93cb393b05dcd7b7165"><code>a16621b</code></a>
Bump pins in README (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/112">#112</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/1c03e047a3633631b1e5648c48243045b1de0d25"><code>1c03e04</code></a>
chore(deps): bump github/codeql-action from 4.35.2 to 4.35.3 in the
github-ac...</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/b572f7b1a1c2d41efaab43d504f68d215c3cd727"><code>b572f7b</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/111">#111</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/06928c5dcba418c7d6108a4bd6e2d34cbf3c9377"><code>06928c5</code></a>
chore(deps): bump github/codeql-action in the github-actions group (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/109">#109</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5ea8b96e1078453e04a1b81443890d9e7da5ddf3"><code>5ea8b96</code></a>
docs: Update link to GitHub docs (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/108">#108</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/849ac260951adeb7c02481da6c7e749b39f4ea6d"><code>849ac26</code></a>
chore(deps): bump the github-actions group with 2 updates (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/106">#106</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/814f9778aceea8641503a8cd8f0cffebc55d790c"><code>814f977</code></a>
Bump pins in README (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/103">#103</a>)</li>
<li>See full diff in <a
href="https://github.com/zizmorcore/zizmor-action/compare/b1d7e1fb5de872772f31590499237e7cce841e8e...5f14fd08f7cf1cb1609c1e344975f152c7ee938d">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 15:03:46 +00:00
ikkz ee219a8b17 fix(git-clone): propagate pre/post-clone script failures (#891)
## Description

Fix git-clone module to fail fast when `pre_clone_script` or
`post_clone_script` returns a non-zero exit code. Previously, both
scripts were executed but their exit codes were never checked — a
failing pre-clone hook (e.g., a prerequisite check that calls `exit 1`)
was silently ignored and cloning continued. This broke the advertised
"validate prerequisites before cloning" behavior and could leave
workspaces starting with unmet preconditions.

## Type of Change

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

## Module Information

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

## Testing & Validation

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

## Related Issues

- https://github.com/coder/registry/pull/887#issuecomment-4413765491
- https://github.com/coder/registry/issues/60
- https://github.com/coder/registry/issues/86
2026-05-16 17:33:27 -05:00
Morgan Lunt 4ca251f448 feat(claude-code): add managed_settings input for policy delivery via /etc/claude-code (#863)
## Problem

The module configures Claude Code's permission posture by reaching
around the permission system rather than through it:

- `scripts/install.sh` writes `bypassPermissionsModeAccepted`,
`autoModeAccepted`, and `primaryApiKey` directly into the user-writable
`~/.claude.json`. Any process in the workspace can read the API key or
flip the acceptance flags back.
- `scripts/start.sh` adds `--dangerously-skip-permissions` to every task
launch, even when the template author set an explicit `permission_mode`.
The README has to carry a security warning telling people the module
bypasses permission checks.
- `permission_mode`, `allowed_tools`, and `disallowed_tools` each plumb
through a different ad-hoc path (CLI flag, `coder` subcommand) instead
of a single policy surface.

## Change

Add a `managed_settings` input that renders to
`/etc/claude-code/managed-settings.d/10-coder.json`. Claude Code reads
that drop-in directory at startup with the highest configuration
precedence (above `~/.claude/settings.json` and project settings), so
template authors get an admin-controlled policy file that users inside
the workspace cannot override. The mechanism is a local file read with
no API call, so it works identically for the Anthropic API, AWS Bedrock,
Google Vertex AI, and AI Bridge / AI Gateway.

```hcl
managed_settings = {
  permissions = {
    defaultMode                  = "acceptEdits"
    disableBypassPermissionsMode = "disable"
    deny                         = ["Bash(curl:*)", "WebFetch"]
  }
}
```

Supporting changes:

- `install.sh` writes the policy file (root-owned, 0644) and stops
writing `bypassPermissionsModeAccepted`, `autoModeAccepted`, and
`primaryApiKey` into `~/.claude.json`. The API key is already exported
via `coder_env` as `CLAUDE_API_KEY`; duplicating it on disk is
unnecessary. `hasCompletedOnboarding` stays because there is no env-var
alternative for it.
- `start.sh` only adds `--dangerously-skip-permissions` for tasks when
no explicit `permission_mode` is set (same fix as #846; included here so
this PR is self-contained, happy to drop if #846 lands first).
- `permission_mode`, `allowed_tools`, and `disallowed_tools` are marked
deprecated and shimmed into `managed_settings.permissions` for one
release when `managed_settings` is not provided.
- README security warning rewritten to point at the policy mechanism
instead of telling people the module is unsafe by design.

## Relationship to #861

#861 strips this module to install-and-configure and removes
`permission_mode` / `allowed_tools` / `disallowed_tools` outright.
`managed_settings` is the natural replacement for those: it is
install-time (survives the `start.sh` removal), it covers everything the
dropped variables did plus `hooks`, `env`, `model`, `apiKeyHelper`, and
the rest of the settings schema, and it does not require the module to
know anything about how Claude is launched. If #861 lands first I will
rebase this on top and drop the deprecation shim and the `start.sh`
hunk.

## Validation

- `terraform fmt` / `terraform validate` clean
- New tests: `claude-managed-settings-written`,
`claude-managed-settings-legacy-shim`,
`claude-no-policy-keys-in-claudejson`, plus an assertion in
`claude-auto-permission-mode` that `--dangerously-skip-permissions` is
absent when a mode is set
- Manually verified `/etc/claude-code/managed-settings.d/*.json`
precedence in the Claude Code CLI source

Closes #818. Relates to #284, #846, #861.

Disclosure: I work at Anthropic on the Claude Code team. Happy to adjust
scope or split this further if that is easier to review.

---------

Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-05-15 08:27:42 -05:00
41 changed files with 1762 additions and 654 deletions
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
@@ -87,13 +87,13 @@ jobs:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 # v1.47.0
with:
config: .github/typos.toml
validate-readme-files:
+2 -1
View File
@@ -9,11 +9,12 @@ on:
# Matches release/<namespace>/<resource_name>/<semantic_version>
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
- "release/*/*/v*.*.*"
branches: # Templates get released when merged to main
branches: # Templates and skills get released when merged to main
- main
paths:
- ".github/workflows/deploy-registry.yaml"
- "registry/**/templates/**"
- "registry/**/skills/**"
- "registry/**/README.md"
- ".icons/**"
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9
with:
version: v2.1
+1
View File
@@ -33,6 +33,7 @@ jobs:
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
echo "module=$MODULE" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
- name: Install dependencies
run: bun install
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
advanced-security: false
annotations: true
@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
inputs: |
.github/workflows
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="14" y="3" rx="1"/>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="7" x="3" y="3" rx="1"/>
<rect width="9" height="7" x="3" y="14" rx="1"/>
<rect width="5" height="7" x="16" y="14" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+339
View File
@@ -0,0 +1,339 @@
package main
import (
"bufio"
"context"
"errors"
"os"
"path"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used
// in the skills README sources frontmatter. Owners and repo names allow
// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus
// forward slashes for paths like refs/heads/main.
var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`)
// skillsIconPrefix is the relative path prefix from a skills README to the
// repo-level .icons directory. The skills README lives at depth 3
// (registry/<namespace>/skills/README.md), so the prefix is three levels up.
// This is distinct from modules and templates, which live at depth 4 and use
// "../../../../.icons/".
const skillsIconPrefix = "../../../.icons/"
// skillOverride holds per-skill presentation metadata defined in the
// registry README. All fields are optional.
type skillOverride struct {
DisplayName string `yaml:"display_name"`
Description string `yaml:"description"`
Icon string `yaml:"icon"`
Tags []string `yaml:"tags"`
}
// skillSource is one entry in the sources list, describing a single source
// repo and optional per-skill overrides.
type skillSource struct {
Repo string `yaml:"repo"`
Skills map[string]skillOverride `yaml:"skills"`
}
// coderSkillsFrontmatter is the YAML frontmatter schema for
// registry/<namespace>/skills/README.md.
type coderSkillsFrontmatter struct {
Icon string `yaml:"icon"`
Sources []skillSource `yaml:"sources"`
}
// supportedSkillsTopLevelKeys lists the keys allowed at the root of the
// skills README frontmatter. Nested keys under sources are validated
// separately because the typed unmarshal handles them.
var supportedSkillsTopLevelKeys = []string{"icon", "sources"}
// coderSkillsReadme represents a parsed skills README file.
type coderSkillsReadme struct {
filePath string
body string
frontmatter coderSkillsFrontmatter
}
// separateSkillsFrontmatter is like separateFrontmatter but preserves
// indentation in the frontmatter block. The skills README uses nested YAML
// (per-skill metadata under each source), which the indentation-trimming
// behavior of the shared separateFrontmatter helper destroys.
func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) {
if readmeText == "" {
return "", "", xerrors.New("README is empty")
}
const fence = "---"
var fmBuilder strings.Builder
var bodyBuilder strings.Builder
fenceCount := 0
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
for lineScanner.Scan() {
nextLine := lineScanner.Text()
if fenceCount < 2 && strings.TrimSpace(nextLine) == fence {
fenceCount++
continue
}
if fenceCount == 0 {
break
}
if fenceCount >= 2 {
bodyBuilder.WriteString(nextLine)
bodyBuilder.WriteString("\n")
} else {
fmBuilder.WriteString(nextLine)
fmBuilder.WriteString("\n")
}
}
if fenceCount < 2 {
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
}
if strings.TrimSpace(fmBuilder.String()) == "" {
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
}
return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil
}
// isPermittedSkillsIconURL validates that an icon URL references the
// repo-level .icons directory using the 3-deep prefix appropriate for
// skills READMEs, and that the file exists on disk.
func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error {
if !strings.HasPrefix(checkURL, skillsIconPrefix) {
return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix)
}
readmeDir := path.Dir(readmeFilePath)
resolvedPath := path.Join(readmeDir, checkURL)
if _, err := os.Stat(resolvedPath); err != nil {
if os.IsNotExist(err) {
return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
}
return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
}
return nil
}
func validateSkillsIconURL(iconURL string, filePath string) []error {
if iconURL == "" {
return nil
}
var errs []error
if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
return errs
}
if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil {
errs = append(errs, err)
}
return errs
}
// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter
// as a YAML map and verifies that every top-level key is in the supported
// set. This catches typos like "source:" vs "sources:".
func validateSkillsTopLevelKeys(fm string) []error {
var rawKeys map[string]any
if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil {
return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)}
}
var errs []error
for key := range rawKeys {
if !slices.Contains(supportedSkillsTopLevelKeys, key) {
errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", ")))
}
}
return errs
}
func validateSkillsSources(sources []skillSource, filePath string) []error {
if len(sources) == 0 {
return []error{xerrors.New("at least one source repo is required under 'sources'")}
}
var errs []error
for i, src := range sources {
if src.Repo == "" {
errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i))
continue
}
if !skillsRepoSpecRe.MatchString(src.Repo) {
errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo))
}
for slug, override := range src.Skills {
if !validNameRe.MatchString(slug) {
errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug))
}
for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) {
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr))
}
// validateCoderResourceTags returns an error for nil tags, which is
// fine for modules/templates that require tags but not for skills
// where tags are an optional override.
if override.Tags != nil {
if err := validateCoderResourceTags(override.Tags); err != nil {
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err))
}
}
}
}
return errs
}
func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error {
var errs []error
for _, err := range validateSkillsIconURL(fm.Icon, filePath) {
errs = append(errs, addFilePathToError(filePath, err))
}
for _, err := range validateSkillsSources(fm.Sources, filePath) {
errs = append(errs, addFilePathToError(filePath, err))
}
return errs
}
func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) {
fm, body, err := separateSkillsFrontmatter(rm.rawText)
if err != nil {
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateSkillsTopLevelKeys(fm)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return coderSkillsReadme{}, remapped
}
yml := coderSkillsFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return coderSkillsReadme{
filePath: rm.filePath,
body: body,
frontmatter: yml,
}, nil
}
func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) {
var parsed []coderSkillsReadme
var parsingErrs []error
for _, rm := range rms {
p, errs := parseCoderSkillsReadme(rm)
if len(errs) != 0 {
parsingErrs = append(parsingErrs, errs...)
continue
}
parsed = append(parsed, p)
}
if len(parsingErrs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: parsingErrs,
}
}
return parsed, nil
}
func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error {
var validationErrs []error
for _, rm := range readmes {
errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter)
if len(errs) > 0 {
validationErrs = append(validationErrs, errs...)
}
}
if len(validationErrs) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: validationErrs,
}
}
return nil
}
// aggregateSkillsReadmeFiles walks registry/<namespace>/skills/README.md
// entries, skipping namespaces that do not have a skills directory.
func aggregateSkillsReadmeFiles() ([]readme, error) {
namespaceDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
}
var allReadmeFiles []readme
var errs []error
for _, nDir := range namespaceDirs {
if !nDir.IsDir() {
continue
}
skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md")
rmBytes, err := os.ReadFile(skillsReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
errs = append(errs, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
filePath: skillsReadmePath,
rawText: string(rmBytes),
})
}
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFile,
errors: errs,
}
}
return allReadmeFiles, nil
}
func validateAllCoderSkills() error {
allReadmeFiles, err := aggregateSkillsReadmeFiles()
if err != nil {
return err
}
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
if len(allReadmeFiles) == 0 {
return nil
}
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
if err != nil {
return err
}
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
return err
}
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
return nil
}
+4
View File
@@ -39,6 +39,10 @@ func main() {
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderSkills()
if err != nil {
errs = append(errs, err)
}
if len(errs) == 0 {
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
// validNameRe validates that names contain only alphanumeric characters and hyphens
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
+1 -1
View File
@@ -8,4 +8,4 @@ status: community
# Ben Potter
Tinkerer and Product Manager at Coder. Building modules to make dev environments better.
Tinkerer and Product Manager at Coder.
@@ -0,0 +1,75 @@
---
display_name: Incus NixOS VM
description: Run a NixOS virtual machine on a local Incus host
icon: ../../../../.icons/lxc.svg
verified: false
tags: [local, incus, vm, nixos]
---
# Incus NixOS VM
Provision a NixOS KVM virtual machine on an [Incus](https://linuxcontainers.org/incus/) host. The image is pulled from [images.linuxcontainers.org](https://images.linuxcontainers.org) and cached on the host.
NixOS does not support cloud-init. This template uses `nixos-rebuild switch` via `incus exec` to configure the workspace user and start the Coder agent. The rebuild only runs on first boot; subsequent starts rotate the agent token and restart the service directly.
## Prerequisites
### 1. Install Incus on the VM host
Follow the [Incus installation guide](https://linuxcontainers.org/incus/docs/main/installing/) for your distro. On Debian/Ubuntu:
```sh
sudo apt-get install incus
sudo incus admin init
```
### 2. Run the Coder provisioner on the same machine
This template uses the local Incus socket, so the Coder provisioner must run on the same machine as Incus. See [provisioner daemons](https://coder.com/docs/admin/provisioners).
### 3. Ensure the host has KVM
```sh
ls /dev/kvm
```
If the device is missing, enable virtualisation in your BIOS/UEFI or, in a nested setup, pass through the `kvm` module.
### 4. Create a storage pool (if needed)
```sh
incus storage create default btrfs
incus storage list
```
### 5. Push the template
```sh
# amd64 host:
coder templates push incus-nixos --directory . --variable arch=amd64
# arm64 host:
coder templates push incus-nixos --directory . --variable arch=arm64
```
The `storage_pool` variable defaults to `default`. Override if needed:
```sh
coder templates push incus-nixos --directory . \
--variable arch=arm64 \
--variable storage_pool=fast-nvme
```
The `nixos_channel` variable controls which NixOS channel is used for `nixos-rebuild`. It must match the image version (default: `nixos-25.11`).
## Usage
1. Create a workspace from this template and choose CPU, memory, and disk.
2. Connect via `coder ssh <workspace>` or use VS Code in the browser via the [VS Code extension](https://coder.com/docs/user-guides/workspace-access/vscode).
3. Install packages declaratively by editing `/etc/nixos/coder.nix` and running `sudo nixos-rebuild switch`.
## Notes
- `code-server` is not installed by this template. The Coder agent is started as a plain systemd service. Install editors via nix packages in `coder.nix`.
- The first workspace start takes several minutes while `nixos-rebuild switch` runs. Subsequent starts are fast.
- Advanced Incus remotes (targeting a separate host over the network) are not supported by this template. See the `incus-vm` template for guidance on adding remote support.
@@ -0,0 +1,287 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.4.0"
}
incus = {
source = "lxc/incus"
version = "~> 1.0"
}
null = {
source = "hashicorp/null"
version = "~> 3.0"
}
}
}
provider "incus" {}
variable "arch" {
description = "CPU architecture of the VM host. Set this when pushing the template to match your Incus host. Valid values: amd64, arm64."
type = string
default = "amd64"
validation {
condition = contains(["amd64", "arm64"], var.arch)
error_message = "arch must be amd64 or arm64."
}
}
variable "storage_pool" {
description = "Incus storage pool for the root disk. Run `incus storage list` on the host to see available pools."
type = string
default = "default"
}
variable "nixos_channel" {
description = "NixOS channel to use for nixos-rebuild. Must match the image version (e.g. nixos-25.11)."
type = string
default = "nixos-25.11"
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU"
description = "Number of vCPUs."
type = "number"
default = 2
icon = "https://raw.githubusercontent.com/matifali/logos/main/cpu-3.svg"
mutable = true
order = 1
validation {
min = 1
max = 16
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory (GB)"
type = "number"
default = 4
icon = "/icon/memory.svg"
mutable = true
order = 2
validation {
min = 1
max = 64
}
}
data "coder_parameter" "disk" {
name = "disk"
display_name = "Disk (GB)"
type = "number"
default = 30
icon = "/icon/database.svg"
mutable = true
order = 3
validation {
min = 10
max = 500
}
}
locals {
workspace_user = lower(data.coder_workspace_owner.me.name)
agent_token = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].token : ""
agent_init_script = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].init_script : ""
# NixOS images on images.linuxcontainers.org use "nixos/<ver>" with no arch suffix.
# The channel version (e.g. "25.11") is extracted from var.nixos_channel.
nixos_version = replace(var.nixos_channel, "nixos-", "")
image_alias = "nixos/${local.nixos_version}"
# PATH required for incus exec commands on NixOS VMs. The Nix store is not
# on the default system PATH until after the first nixos-rebuild switch.
nix_path = "/nix/var/nix/profiles/system/sw/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/run/wrappers/bin"
}
resource "coder_agent" "main" {
count = data.coder_workspace.me.start_count
arch = var.arch
os = "linux"
}
resource "incus_image" "nixos" {
source_image = {
remote = "images"
name = local.image_alias
type = "virtual-machine"
architecture = var.arch == "amd64" ? "x86_64" : "aarch64"
}
}
resource "incus_instance" "dev" {
running = data.coder_workspace.me.start_count == 1
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
image = incus_image.nixos.fingerprint
type = "virtual-machine"
config = {
"limits.cpu" = tostring(data.coder_parameter.cpu.value)
"limits.memory" = "${data.coder_parameter.memory.value}GiB"
"security.secureboot" = false
"boot.autostart" = data.coder_workspace.me.start_count == 1
"user.coder-agent-token" = local.agent_token
}
device {
name = "root"
type = "disk"
properties = {
path = "/"
pool = var.storage_pool
size = "${data.coder_parameter.disk.value}GiB"
}
}
lifecycle {
ignore_changes = [
config["user.coder-agent-token"],
image,
]
}
}
# NixOS does not support cloud-init. Provisioning steps:
# 1. Wait for the incus-agent to be ready (virtio serial channel).
# 2. Push the Coder agent binary (/opt/coder/init) and token env file.
# 3. On first boot: write coder.nix and an updated configuration.nix
# that imports the Incus VM module, then run nixos-rebuild switch.
# Leave a marker so subsequent starts skip the rebuild.
# 4. On subsequent starts: overwrite init.env + restart coder-agent.
resource "null_resource" "provision" {
count = data.coder_workspace.me.start_count
triggers = {
agent_token = local.agent_token
instance = incus_instance.dev.name
}
depends_on = [incus_instance.dev]
provisioner "local-exec" {
command = <<-EOT
set -e
INSTANCE="${incus_instance.dev.name}"
WUSER="${local.workspace_user}"
NIX_PATH="${local.nix_path}"
CHANNEL="${var.nixos_channel}"
echo "Waiting for incus-agent..."
for i in $(seq 1 60); do
incus exec "$INSTANCE" -- true 2>/dev/null && break
echo " attempt $i/60..."
sleep 5
done
echo "Pushing Coder agent binary..."
TMPDIR=$(mktemp -d)
echo "${base64encode(local.agent_init_script)}" | base64 -d > "$TMPDIR/init"
chmod 755 "$TMPDIR/init"
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" mkdir -p /opt/coder
incus file push "$TMPDIR/init" "$INSTANCE/opt/coder/init"
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" chmod 755 /opt/coder/init
rm -rf "$TMPDIR"
printf 'CODER_AGENT_TOKEN=${local.agent_token}\nCODER_AGENT_URL=${data.coder_workspace.me.access_url}\n' \
| incus file push - "$INSTANCE/opt/coder/init.env" --mode 0600
# Fast path: already provisioned -- just rotate token and restart.
if incus exec "$INSTANCE" -- test -f /etc/nixos/.coder-provisioned 2>/dev/null; then
echo "Already provisioned; restarting coder-agent..."
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" systemctl restart coder-agent.service
echo "Done."
exit 0
fi
# First boot: write NixOS config and rebuild.
echo "Writing /etc/nixos/coder.nix..."
cat <<'NIXEOF' | incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c 'cat > /etc/nixos/coder.nix'
{ config, pkgs, lib, ... }:
{
users.users."${local.workspace_user}" = {
isNormalUser = true;
uid = 1000;
home = "/home/${local.workspace_user}";
shell = pkgs.bash;
extraGroups = [ "wheel" ];
};
security.sudo.wheelNeedsPassword = false;
nix.settings.trusted-users = [ "root" "${local.workspace_user}" ];
systemd.services.coder-agent = {
description = "Coder Agent";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "${local.workspace_user}";
EnvironmentFile = "/opt/coder/init.env";
ExecStart = "/opt/coder/init";
Environment = "PATH=/run/current-system/sw/bin:/run/wrappers/bin:/usr/local/bin:/usr/bin:/bin";
Restart = "always";
RestartSec = 10;
TimeoutStopSec = 90;
KillMode = "process";
OOMScoreAdjust = -900;
SyslogIdentifier = "coder-agent";
};
};
}
NIXEOF
echo "Writing /etc/nixos/configuration.nix..."
cat <<'NIXEOF' | incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c 'cat > /etc/nixos/configuration.nix'
{ modulesPath, ... }:
{
imports = [
"$${modulesPath}/virtualisation/incus-virtual-machine.nix"
./incus.nix
./coder.nix
];
systemd.network = {
enable = true;
networks."50-enp5s0" = {
matchConfig.Name = "enp5s0";
networkConfig = {
DHCP = "ipv4";
IPv6AcceptRA = true;
};
linkConfig.RequiredForOnline = "routable";
};
};
system.stateVersion = "${local.nixos_version}";
}
NIXEOF
echo "Restoring nixos channel if needed..."
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" HOME=/root bash -c "
if [ ! -e /nix/var/nix/profiles/per-user/root/channels/nixos ]; then
nix-channel --add https://channels.nixos.org/$CHANNEL nixos
nix-channel --update nixos
fi
"
echo "Running nixos-rebuild switch..."
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" HOME=/root bash -c "
NIXOS_CH=\$(ls -d /nix/var/nix/profiles/per-user/root/channels/nixos 2>/dev/null || echo '')
nixos-rebuild switch -I nixpkgs=\"\$NIXOS_CH\" -I nixos-config=/etc/nixos/configuration.nix \
|| { EC=\$?; [ \$EC -eq 4 ] || exit \$EC; }
"
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" touch /etc/nixos/.coder-provisioned
incus exec "$INSTANCE" -- env PATH="$NIX_PATH" bash -c \
"mkdir -p /home/$WUSER && chown 1000:1000 /home/$WUSER"
echo "NixOS provisioning complete."
EOT
}
}
@@ -0,0 +1,96 @@
---
display_name: Incus VM
description: Run a full virtual machine on a local Incus host
icon: ../../../../.icons/lxc.svg
verified: false
tags: [local, incus, vm, virtual-machine]
---
# Incus VM
Provision a full KVM virtual machine on an [Incus](https://linuxcontainers.org/incus/) host. Unlike the system container template, this creates an isolated VM with its own kernel. Images are pulled from [images.linuxcontainers.org](https://images.linuxcontainers.org) and cached on the host.
## Prerequisites
### 1. Install Incus on the VM host
Follow the [Incus installation guide](https://linuxcontainers.org/incus/docs/main/installing/) for your distro. On Debian/Ubuntu:
```sh
sudo apt-get install incus
sudo incus admin init
```
Verify it's working:
```sh
incus list
```
### 2. Run the Coder provisioner on the same machine
This template uses Incus via the local Unix socket, so the Coder provisioner (or `coderd` itself) must run on the same machine as Incus. The simplest setup is a [provisioner daemon](https://coder.com/docs/admin/provisioners) running directly on the Incus host.
### 3. Set the architecture when pushing the template
The `arch` variable must match your Incus host's CPU architecture. Pass it when pushing:
```sh
# For amd64 (x86-64) hosts:
coder templates push incus-vm --directory . --variable arch=amd64
# For arm64 (aarch64) hosts:
coder templates push incus-vm --directory . --variable arch=arm64
```
### 4. Ensure the VM host has KVM
VMs require hardware virtualisation. Check on the host:
```sh
ls /dev/kvm
```
If the device is missing, enable virtualisation in your BIOS/UEFI or, in a nested setup, pass through the `kvm` module.
### 5. Create a storage pool (if needed)
The template uses an Incus storage pool to back the VM root disk. If you don't already have one:
```sh
incus storage create default btrfs
```
List existing pools:
```sh
incus storage list
```
The pool name defaults to `default` and can be overridden when pushing the template with `--variable storage_pool=<name>`.
## Usage
1. Push this template to your Coder deployment:
```sh
coder templates push incus-vm --directory . --variable arch=amd64
```
2. Create a workspace and select an image and resource sizes.
3. Connect via `coder ssh <workspace>` or open VS Code in the browser.
## Advanced: using a remote Incus host
By default this template connects to the local Incus socket. If you want the provisioner to target a separate Incus host over the network, add a `remote` parameter and use `incus remote add` to register the host on the provisioner machine:
```sh
# On the Incus host — generate a trust token:
incus config trust add coder-provisioner
# On the provisioner — add the remote:
incus remote add my-server https://<host-ip>:8443 --token <paste-token>
```
Then update `main.tf` to accept a `remote` parameter and pass it to the `incus_image` and `incus_instance` resources. See the [Incus remote docs](https://linuxcontainers.org/incus/docs/main/remotes/) for details.
+304
View File
@@ -0,0 +1,304 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.4.0"
}
incus = {
source = "lxc/incus"
version = "~> 1.0"
}
null = {
source = "hashicorp/null"
version = "~> 3.0"
}
}
}
provider "incus" {}
variable "arch" {
description = "CPU architecture of the VM host. Set this when pushing the template to match your Incus host. Valid values: amd64, arm64."
type = string
default = "amd64"
validation {
condition = contains(["amd64", "arm64"], var.arch)
error_message = "arch must be amd64 or arm64."
}
}
variable "storage_pool" {
description = "Incus storage pool for the root disk. Run `incus storage list` on the host to see available pools."
type = string
default = "default"
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "image" {
name = "image"
display_name = "Image"
description = "Base image name from images.linuxcontainers.org (e.g. `ubuntu/noble/cloud`). The template architecture is appended automatically."
type = "string"
default = "ubuntu/noble/cloud"
icon = "/icon/image.svg"
mutable = true
order = 1
option {
name = "Ubuntu 24.04 LTS (Noble)"
value = "ubuntu/noble/cloud"
icon = "/icon/ubuntu.svg"
}
option {
name = "Ubuntu 22.04 LTS (Jammy)"
value = "ubuntu/jammy/cloud"
icon = "/icon/ubuntu.svg"
}
option {
name = "Debian 12 (Bookworm)"
value = "debian/12/cloud"
icon = "/icon/debian.svg"
}
}
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU"
description = "Number of vCPUs."
type = "number"
default = 2
icon = "https://raw.githubusercontent.com/matifali/logos/main/cpu-3.svg"
mutable = true
order = 2
validation {
min = 1
max = 16
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory (GB)"
type = "number"
default = 4
icon = "/icon/memory.svg"
mutable = true
order = 3
validation {
min = 1
max = 64
}
}
data "coder_parameter" "disk" {
name = "disk"
display_name = "Disk (GB)"
type = "number"
default = 30
icon = "/icon/database.svg"
mutable = true
order = 4
validation {
min = 10
max = 500
}
}
resource "coder_agent" "main" {
count = data.coder_workspace.me.start_count
arch = var.arch
os = "linux"
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Disk"
key = "2_disk"
script = "coder stat disk --path /"
interval = 60
timeout = 1
}
}
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main[0].id
}
resource "incus_image" "image" {
source_image = {
remote = "images"
name = "${data.coder_parameter.image.value}/${var.arch}"
type = "virtual-machine"
architecture = var.arch == "amd64" ? "x86_64" : "aarch64"
}
}
resource "incus_instance" "dev" {
running = data.coder_workspace.me.start_count == 1
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
image = incus_image.image.fingerprint
type = "virtual-machine"
config = {
"limits.cpu" = tostring(data.coder_parameter.cpu.value)
"limits.memory" = "${data.coder_parameter.memory.value}GiB"
"security.secureboot" = false
"boot.autostart" = data.coder_workspace.me.start_count == 1
"user.coder-agent-token" = local.agent_token
"cloud-init.user-data" = <<-EOF
#cloud-config
hostname: ${lower(data.coder_workspace.me.name)}
users:
- name: ${local.workspace_user}
uid: 1000
groups: sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
write_files:
- path: /opt/coder/init
permissions: "0755"
encoding: b64
content: ${base64encode(local.agent_init_script)}
- path: /opt/coder/init.env
permissions: "0600"
content: |
CODER_AGENT_TOKEN=${local.agent_token}
CODER_AGENT_URL=${data.coder_workspace.me.access_url}
- path: /etc/systemd/system/coder-agent.service
permissions: "0644"
content: |
[Unit]
Description=Coder Agent
After=network-online.target
Wants=network-online.target
[Service]
User=${local.workspace_user}
EnvironmentFile=/opt/coder/init.env
ExecStart=/opt/coder/init
Restart=always
RestartSec=10
TimeoutStopSec=90
KillMode=process
OOMScoreAdjust=-900
SyslogIdentifier=coder-agent
[Install]
WantedBy=multi-user.target
runcmd:
- systemctl enable --now coder-agent.service
EOF
}
device {
name = "root"
type = "disk"
properties = {
path = "/"
pool = var.storage_pool
size = "${data.coder_parameter.disk.value}GiB"
}
}
lifecycle {
ignore_changes = [
config["cloud-init.user-data"],
config["user.coder-agent-token"],
image,
]
}
}
resource "null_resource" "token_refresh" {
count = data.coder_workspace.me.start_count
triggers = {
agent_token = local.agent_token
instance = incus_instance.dev.name
}
depends_on = [incus_instance.dev]
provisioner "local-exec" {
command = <<-EOT
INSTANCE="${incus_instance.dev.name}"
echo "Waiting for VM agent..."
for i in $(seq 1 40); do
incus exec "$INSTANCE" -- true 2>/dev/null && break
echo "Attempt $i: not ready, waiting..."
sleep 5
done
echo "Waiting for cloud-init..."
incus exec "$INSTANCE" -- bash -c '
for i in $(seq 1 60); do
[ -f /var/lib/cloud/instance/boot-finished ] && break
sleep 5
done
'
echo "Refreshing agent token..."
printf 'CODER_AGENT_TOKEN=${local.agent_token}\nCODER_AGENT_URL=${data.coder_workspace.me.access_url}\n' \
| incus exec "$INSTANCE" -- bash -c 'cat > /opt/coder/init.env && chmod 600 /opt/coder/init.env'
incus exec "$INSTANCE" -- systemctl restart coder-agent.service
EOT
}
}
resource "coder_metadata" "info" {
count = data.coder_workspace.me.start_count
resource_id = incus_instance.dev.name
item {
key = "instance"
value = incus_instance.dev.name
}
item {
key = "image"
value = "images:${data.coder_parameter.image.value}/${var.arch}"
}
item {
key = "storage_pool"
value = var.storage_pool
}
item {
key = "arch"
value = var.arch
}
item {
key = "cpu"
value = tostring(data.coder_parameter.cpu.value)
}
item {
key = "memory"
value = "${data.coder_parameter.memory.value} GiB"
}
item {
key = "disk"
value = "${data.coder_parameter.disk.value} GiB"
}
}
locals {
workspace_user = lower(data.coder_workspace_owner.me.name)
agent_token = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].token : ""
agent_init_script = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].init_script : ""
}
@@ -1,73 +0,0 @@
---
display_name: Claude Code self-hosted runner
description: Run Anthropic's Claude Code self-hosted runner as a long-lived process inside a Coder workspace, with per-workspace scoped self-eviction so the prebuild reconciler keeps the pool warm.
icon: ../../../../.icons/claude.svg
verified: false
tags: [ai, claude, claude-code, anthropic, runner]
---
# Claude Code self-hosted runner
Drops Anthropic's [Claude Code self-hosted runner](https://docs.anthropic.com/en/docs/claude-code/self-hosted-runners) into any Coder template that has a `coder_agent` and a workspace image with the runner binary installed (`/usr/local/bin/claude self-hosted-runner` by default).
The module owns the runner script (writes a per-session wrapper that forces `--permission-mode bypassPermissions`, then spawns a detached supervisor that runs the runner in the foreground and POSTs a delete build to self-evict on drain), the agent environment variables it needs, an optional bot-git askpass setup, and a host Docker socket gid fixup. Agent metadata items (lock status, active sessions, runner ID, last poll) are emitted via the `agent_metadata` output for the parent to splat into a `dynamic "metadata"` block.
The parent template still owns the `coder_agent` itself, the per-workspace scope-restricted self-evict token (minted via the `Mastercard/restapi` provider against an admin bootstrap token), the prebuild preset, and the infra block (`docker_container`, `kubernetes_pod`, etc.).
> [!IMPORTANT]
> This module is part of the [Claude Code self-hosted runners on Coder](https://coder.com/docs/ai-coder/claude-code-self-hosted-runners) recipe, which currently targets Anthropic's EAP build of the runner. Both the runner binary and the wire contract are still evolving; expect API drift until Anthropic ships GA.
## Usage
```tf
module "claude_self_hosted_runner" {
source = "registry.coder.com/coder-labs/claude-self-hosted-runner/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
workspace_id = data.coder_workspace.me.id
pool_secret = var.pool_secret
self_evict_token = jsondecode(restapi_object.self_evict_token.api_response).key
git_bot_token = var.git_bot_token
capacity = tonumber(data.coder_parameter.capacity.value)
}
resource "coder_agent" "main" {
# ... arch, os, dir, startup_script_behavior, etc.
# Static metadata blocks coexist with the dynamic block below;
# Terraform concatenates them on the same coder_agent.
metadata {
display_name = "CPU"
key = "cpu"
script = "top -bn1 | awk '/Cpu/ {print $2 \"%\"}'"
interval = 10
timeout = 5
}
dynamic "metadata" {
for_each = module.claude_self_hosted_runner.agent_metadata
content {
display_name = metadata.value.display_name
key = metadata.value.key
interval = metadata.value.interval
timeout = metadata.value.timeout
script = metadata.value.script
}
}
}
```
## What the module does
- Writes `$HOME/.claude/wrapper.sh` at agent start. The wrapper appends `--permission-mode bypassPermissions` after `"$@"` so unattended sessions never stall on a tool-approval prompt; Claude Code's flag parser is last-occurrence-wins, so this overrides the server-supplied permission mode.
- Sets up the runner's required environment (`CLAUDE_POOL_SECRET`, `CLAUDE_CAPACITY`, `GIT_BOT_TOKEN`, `CODER_SELF_TOKEN`, `CODER_WORKSPACE_ID`) via `coder_env` resources on the agent.
- Spawns a `setsid nohup` supervisor that runs the runner in the foreground. When the runner exits on drain, the supervisor POSTs `/api/v2/workspaces/{id}/builds` with `{"transition":"delete"}` to self-evict, so Coder's prebuild reconciler can queue a replacement.
- Wires up `GIT_ASKPASS` if `git_bot_token` is supplied so the runner's child claude can `git push` without baking credentials into the image.
- If the parent template mounts the host Docker socket at `/var/run/docker.sock` and the gid does not match the in-container `docker` group, chgrps the socket so the workspace user can use it without sudo.
## Self-eviction security model
The `self_evict_token` input is minted by the parent template via the `Mastercard/restapi` provider at template build time, against an admin bootstrap token that lives in Terraform state and is never injected into the workspace. The minted token is scoped to `workspace:delete + workspace:read + template:read + user:read` and allow-listed to this single workspace's UUID. A leaked copy can do exactly one thing: delete this one workspace. No read of peer prebuilds, no SSH, no external auth, no git creds.
The supervisor uses raw `curl` against `/api/v2/workspaces/{id}/builds`, not the `coder delete` CLI. The CLI fetches workspace resources first, which fails against the scoped token whose allow-list intersection excludes peer workspaces.
@@ -1,185 +0,0 @@
terraform {
required_version = ">= 1.5"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.13"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "workspace_id" {
type = string
description = "data.coder_workspace.me.id from the parent template. Used by the supervisor to self-evict via the workspace builds endpoint."
}
variable "pool_secret" {
type = string
description = "Claude Code self-hosted runner pool secret (from claude.ai)."
sensitive = true
}
variable "self_evict_token" {
type = string
description = "Per-workspace, scope-restricted Coder API token. Scope = workspace:delete + workspace:read + template:read + user:read, allow_list = this workspace's UUID. A leaked copy can only delete this one workspace. The parent template mints it via the Mastercard/restapi provider at build time."
sensitive = true
}
variable "git_bot_token" {
type = string
description = "Optional git PAT for the bot identity. Wired through GIT_ASKPASS so the runner's child claude can push without baking credentials into the image."
sensitive = true
default = ""
}
variable "capacity" {
type = number
description = "Maximum sessions the runner serves at once. The runner locks to one Anthropic user; this caps parallelism within that user's queue."
default = 4
validation {
condition = var.capacity >= 1 && var.capacity <= 16
error_message = "capacity must be between 1 and 16."
}
}
variable "runner_binary_path" {
type = string
description = "Path to the `claude self-hosted-runner` binary inside the workspace."
default = "/usr/local/bin/claude"
}
variable "claude_binary_path" {
type = string
description = "Path to the Claude Code binary the wrapper execs for each session."
default = "/opt/claude/claude"
}
variable "order" {
type = number
description = "Order of the runner script in the agent UI."
default = null
}
resource "coder_env" "pool_secret" {
agent_id = var.agent_id
name = "CLAUDE_POOL_SECRET"
value = var.pool_secret
}
resource "coder_env" "capacity" {
agent_id = var.agent_id
name = "CLAUDE_CAPACITY"
value = tostring(var.capacity)
}
resource "coder_env" "git_bot_token" {
agent_id = var.agent_id
name = "GIT_BOT_TOKEN"
value = var.git_bot_token
}
resource "coder_env" "self_token" {
agent_id = var.agent_id
name = "CODER_SELF_TOKEN"
value = var.self_evict_token
}
resource "coder_env" "workspace_id" {
agent_id = var.agent_id
name = "CODER_WORKSPACE_ID"
value = var.workspace_id
}
resource "coder_script" "claude_runner" {
agent_id = var.agent_id
display_name = "Claude self-hosted runner"
icon = "/icon/code.svg"
run_on_start = true
start_blocks_login = false
script = templatefile("${path.module}/scripts/run.sh", {
CLAUDE_BINARY_PATH = var.claude_binary_path
RUNNER_BINARY_PATH = var.runner_binary_path
})
}
# Agent metadata items. The parent splats this list into a
# `dynamic "metadata"` block on its own `coder_agent` because nested
# blocks cannot be injected from a module. Scraped from the runner's
# local /healthz and /metrics endpoints; this is the only window a
# Coder admin has into who the Anthropic pool has bound this workspace
# to (the runner does not expose the locked user's email over its
# local endpoints; that lives in claude.ai > Self-hosted runner pools).
output "agent_metadata" {
description = "List of agent metadata items the parent template should splat into a `dynamic \"metadata\"` block on its coder_agent."
value = [
{
display_name = "Lock status"
key = "0_lock_status"
interval = 10
timeout = 5
# The runner does not expose its locked state via /metrics or
# /healthz in the current BYOC build, so we infer it from
# active_sessions and latch a sticky flag on disk: once a
# session has landed, the runner is locked to that Anthropic
# user for its entire lifetime per Anthropic's spec, even when
# the active count drops back to zero between sessions.
script = <<-EOT
flag="$HOME/.claude/locked"
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
| jq -r '.active_sessions // 0')
if [ "$${active:-0}" -gt 0 ] && [ ! -f "$flag" ]; then
touch "$flag" 2>/dev/null || true
fi
if [ -f "$flag" ]; then
printf 'locked'
else
printf 'unlocked'
fi
EOT
},
{
display_name = "Active sessions"
key = "1_active_sessions"
interval = 5
timeout = 5
script = <<-EOT
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
| jq -r '.active_sessions // empty')
if [ -z "$active" ]; then echo '?'; exit 0; fi
printf '%s / %s' "$active" "$${CLAUDE_CAPACITY:-1}"
EOT
},
{
display_name = "Runner ID"
key = "2_runner_id"
interval = 30
timeout = 5
script = <<-EOT
curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
| jq -r '.runner_id // "(starting)"'
EOT
},
{
display_name = "Last Anthropic poll"
key = "3_last_poll"
interval = 15
timeout = 5
script = <<-EOT
age=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
| jq -r '.last_poll_age_ms // empty')
if [ -z "$age" ]; then echo '?'; exit 0; fi
if [ "$age" -lt 30000 ]; then
printf 'ok (%sms ago)' "$age"
else
printf 'stale (%ss ago)' $((age/1000))
fi
EOT
},
]
}
@@ -1,123 +0,0 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
}
assert {
condition = length(resource.coder_env.pool_secret.value) > 0
error_message = "pool_secret env should be set"
}
assert {
condition = resource.coder_env.capacity.value == "4"
error_message = "default capacity should be 4"
}
assert {
condition = resource.coder_script.claude_runner.display_name == "Claude self-hosted runner"
error_message = "expected the runner coder_script display_name"
}
}
run "custom_capacity_and_binary_paths" {
command = plan
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
capacity = 8
claude_binary_path = "/custom/claude"
runner_binary_path = "/custom/runner"
}
assert {
condition = resource.coder_env.capacity.value == "8"
error_message = "capacity input should flow into CLAUDE_CAPACITY env"
}
assert {
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/claude")
error_message = "claude_binary_path should appear in the rendered script"
}
assert {
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/runner")
error_message = "runner_binary_path should appear in the rendered script"
}
}
run "git_bot_token_optional" {
command = plan
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
}
assert {
condition = resource.coder_env.git_bot_token.value == ""
error_message = "git_bot_token should default to empty string"
}
}
run "capacity_validation_rejects_zero" {
command = plan
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
capacity = 0
}
expect_failures = [
var.capacity,
]
}
run "capacity_validation_rejects_high" {
command = plan
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
capacity = 17
}
expect_failures = [
var.capacity,
]
}
run "agent_metadata_output_has_four_items" {
command = apply
variables {
agent_id = "test-agent"
workspace_id = "test-workspace"
pool_secret = "test-pool-secret"
self_evict_token = "test-self-token"
}
assert {
condition = length(output.agent_metadata) == 4
error_message = "agent_metadata should expose four scraping items"
}
assert {
condition = output.agent_metadata[0].key == "0_lock_status"
error_message = "first metadata item should be lock_status"
}
}
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
# Wires up everything the Claude Code self-hosted runner needs at agent
# start, then spawns a detached supervisor that keeps the runner alive
# and self-evicts on drain.
#
# Runtime env (set by coder_env in main.tf):
# CLAUDE_POOL_SECRET Anthropic pool secret (mandatory).
# CLAUDE_CAPACITY Max parallel sessions per runner (default 1).
# GIT_BOT_TOKEN Optional bot PAT for GIT_ASKPASS.
# CODER_SELF_TOKEN Per-workspace scope-restricted Coder API token.
# CODER_WORKSPACE_ID This workspace's UUID, used by self-eviction.
# CODER_AGENT_URL Set by the Coder agent itself.
set -euo pipefail
CLAUDE_BINARY_PATH='${CLAUDE_BINARY_PATH}'
RUNNER_BINARY_PATH='${RUNNER_BINARY_PATH}'
if [ -z "$${CLAUDE_POOL_SECRET:-}" ]; then
echo "CLAUDE_POOL_SECRET is empty. Set the pool_secret input on the module."
exit 1
fi
install -d -m 0700 "$HOME/.claude"
# --- Bot git askpass ----------------------------------------------------
if [ -n "$${GIT_BOT_TOKEN:-}" ]; then
install -d -m 0700 "$HOME/.git-creds"
cat > "$HOME/.git-creds/askpass.sh" << 'ASK'
#!/bin/sh
printf '%s' "$GIT_BOT_TOKEN"
ASK
chmod 0500 "$HOME/.git-creds/askpass.sh"
git config --global core.askPass "$HOME/.git-creds/askpass.sh"
git config --global credential.helper ''
fi
# --- Host Docker socket gid fixup --------------------------------------
if [ -S /var/run/docker.sock ]; then
sock_gid=$(stat -c %g /var/run/docker.sock)
docker_gid=$(getent group docker | cut -d: -f3 || true)
if [ -n "$${docker_gid:-}" ] && [ "$${sock_gid}" != "$${docker_gid}" ]; then
sudo chgrp "$${docker_gid}" /var/run/docker.sock 2> /dev/null || true
fi
fi
# --- Pool secret on disk -----------------------------------------------
POOL_SECRET_FILE="$HOME/.claude/pool-secret"
rm -f "$POOL_SECRET_FILE"
umask 077
printf '%s' "$${CLAUDE_POOL_SECRET}" > "$POOL_SECRET_FILE"
chmod 0400 "$POOL_SECRET_FILE"
# --- Wrapper script -----------------------------------------------------
# Runner execs this once per session, appending its server-computed
# flags. Claude Code's flag parser is last-occurrence-wins, so flags
# after "$@" win. Force --permission-mode bypassPermissions so
# unattended sessions never stall on a tool-approval prompt.
WRAPPER="$HOME/.claude/wrapper.sh"
{
echo '#!/bin/bash'
echo "exec $${CLAUDE_BINARY_PATH} \"\$@\" --permission-mode bypassPermissions"
} > "$WRAPPER"
chmod 0755 "$WRAPPER"
# --- Supervisor --------------------------------------------------------
# Runs the runner in the foreground; on runner exit POSTs a delete
# build to self-evict. Raw curl, not `coder delete`: the CLI fetches
# workspace resources first, which fails with the per-workspace
# scoped token whose allow-list excludes peer prebuilds.
#
# Single-quoted heredoc, so nothing is expanded by the outer shell.
# The supervisor reads its env vars (CODER_SELF_TOKEN, CODER_AGENT_URL,
# etc.) at runtime, when it's invoked under setsid.
SUPERVISOR="$HOME/.claude/supervisor.sh"
cat > "$SUPERVISOR" << SUP
#!/usr/bin/env bash
set -uo pipefail
exec >>"\$HOME/.claude/supervisor.log" 2>&1
echo "[supervisor] start \$(date -Is)"
$${RUNNER_BINARY_PATH} self-hosted-runner \\
--pool-secret-file "\$HOME/.claude/pool-secret" \\
--capacity "\$${CLAUDE_CAPACITY:-1}" \\
--log-file "\$HOME/.claude/runner.log" \\
--exec-path "\$HOME/.claude/wrapper.sh"
echo "[supervisor] runner exited rc=\$? \$(date -Is)"
if [ -z "\$${CODER_SELF_TOKEN:-}" ]; then
echo "[supervisor] CODER_SELF_TOKEN is empty; skipping self-eviction."
exit 0
fi
http_code=\$(curl -s -o /tmp/evict.out -w "%%{http_code}" \\
-X POST \\
-H "Coder-Session-Token: \$CODER_SELF_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"transition":"delete"}' \\
"\$CODER_AGENT_URL/api/v2/workspaces/\$CODER_WORKSPACE_ID/builds")
if [ "\$http_code" = "201" ]; then
echo "[supervisor] self-eviction queued (HTTP 201)."
else
echo "[supervisor] self-eviction failed (HTTP \$http_code): \$(head -c 300 /tmp/evict.out)"
fi
SUP
chmod 0700 "$SUPERVISOR"
# Detach with setsid + nohup. The supervisor reopens stdout/stderr to
# its own logfile; redirect all standard fds here to /dev/null so this
# script's exit doesn't drag the supervisor with it.
setsid nohup "$SUPERVISOR" < /dev/null > /dev/null 2>&1 &
disown
echo "Runner spawned as detached supervisor (pid=$!). See ~/.claude/supervisor.log."
+6 -6
View File
@@ -13,14 +13,14 @@ Install and configure the [Codex CLI](https://github.com/openai/codex) in your w
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
version = "5.1.0"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
}
```
> [!WARNING]
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
## Examples
@@ -33,7 +33,7 @@ locals {
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = local.codex_workdir
openai_api_key = var.openai_api_key
@@ -64,7 +64,7 @@ resource "coder_app" "codex" {
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
@@ -88,7 +88,7 @@ When `enable_ai_gateway = true`, the module configures Codex to use the `aigatew
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
openai_api_key = var.openai_api_key
@@ -117,7 +117,7 @@ The module exposes the `scripts` output: an ordered list of `coder exp sync` nam
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "5.0.0"
version = "5.1.0"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
}
@@ -189,7 +189,7 @@ describe("codex", async () => {
});
test("install-codex-version", async () => {
const version = "0.10.0";
const version = "0.134.0";
const { id, coderEnvVars, scripts } = await setup({
skipCodexMock: true,
moduleVariables: {
+1 -1
View File
@@ -137,7 +137,7 @@ locals {
EOF
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
ARG_INSTALL = tostring(var.install_codex)
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_CODEX_VERSION = var.codex_version
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
@@ -9,7 +9,7 @@ command_exists() {
}
ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_CODEX_VERSION='${ARG_CODEX_VERSION}'
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
@@ -51,8 +51,6 @@ function ensure_codex_in_path() {
local CODEX_BIN=""
if command -v codex > /dev/null 2>&1; then
CODEX_BIN=$(command -v codex)
elif [ -x "$HOME/.npm-global/bin/codex" ]; then
CODEX_BIN="$HOME/.npm-global/bin/codex"
fi
if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then
@@ -78,35 +76,9 @@ function install_codex() {
return
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
export NVM_DIR="$HOME/.nvm"
. "$NVM_DIR/nvm.sh"
fi
# Detect a package manager for global installs.
if command_exists npm; then
PKG_INSTALL="npm install -g"
if ! command_exists nvm; then
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
fi
elif command_exists pnpm; then
PKG_INSTALL="pnpm add -g"
elif command_exists bun; then
PKG_INSTALL="bun add -g"
else
echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false."
exit 1
fi
printf "%s Installing Codex CLI\n" "$${BOLD}"
if [ -n "$${ARG_CODEX_VERSION}" ]; then
$PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}"
else
$PKG_INSTALL "@openai/codex"
fi
curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_RELEASE="$${ARG_CODEX_VERSION}" CODEX_NON_INTERACTIVE=1 sh
export PATH="$HOME/.local/bin:$PATH"
printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)"
ensure_codex_in_path
}
+35 -8
View File
@@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
@@ -47,7 +47,7 @@ locals {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -78,7 +78,7 @@ resource "coder_app" "claude" {
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
@@ -95,6 +95,33 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
> [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
### Enterprise policy via managed settings
The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
}
env = {
DISABLE_TELEMETRY = "0"
}
}
}
```
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
### Advanced Configuration
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
@@ -102,7 +129,7 @@ This example shows version pinning, a pre-installed binary path, a custom model,
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -166,7 +193,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -252,7 +279,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -309,7 +336,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -350,7 +377,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -382,10 +382,13 @@ describe("claude-code", async () => {
const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
// not user-writable ~/.claude.json acceptance flags.
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
expect(parsed.autoModeAccepted).toBeUndefined();
});
test("standalone-mode-with-oauth-token", async () => {
@@ -413,7 +416,7 @@ describe("claude-code", async () => {
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
});
test("standalone-mode-no-auth", async () => {
@@ -436,6 +439,49 @@ describe("claude-code", async () => {
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("claude-managed-settings-written", async () => {
const { id, scripts } = await setup({
moduleVariables: {
managed_settings: JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
disableBypassPermissionsMode: "disable",
deny: ["Bash(rm -rf*)"],
},
}),
},
});
await runScripts(id, scripts);
const policy = await execContainer(id, [
"bash",
"-c",
"cat /etc/claude-code/managed-settings.d/10-coder.json",
]);
expect(policy.exitCode).toBe(0);
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Wrote Claude Code managed settings");
});
test("claude-managed-settings-not-set", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const resp = await execContainer(id, [
"bash",
"-c",
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("telemetry-otel", async () => {
const { coderEnvVars } = await setup({
moduleVariables: {
@@ -102,6 +102,12 @@ variable "claude_binary_path" {
}
}
variable "managed_settings" {
type = any
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema."
default = null
}
variable "enable_ai_gateway" {
type = bool
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
@@ -237,6 +243,7 @@ locals {
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
})
module_dir_name = ".coder-modules/coder/claude-code"
}
@@ -283,3 +283,47 @@ run "test_workdir_optional" {
error_message = "workdir should default to null when omitted"
}
}
run "test_managed_settings" {
command = plan
variables {
agent_id = "test-agent-managed-settings"
workdir = "/home/coder/project"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(rm -rf*)"]
}
}
}
assert {
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
error_message = "managed_settings should accept the permissions object"
}
assert {
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
error_message = "install script should reference the managed-settings.d drop-in directory"
}
assert {
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
error_message = "install script should embed the base64-encoded managed_settings JSON"
}
}
run "test_managed_settings_default_null" {
command = plan
variables {
agent_id = "test-agent-managed-settings-default"
}
assert {
condition = var.managed_settings == null
error_message = "managed_settings should default to null when omitted"
}
}
@@ -17,6 +17,7 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
@@ -29,6 +30,7 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
echo "--------------------------------"
@@ -144,6 +146,32 @@ function setup_claude_configurations() {
}
function write_managed_settings() {
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
return
fi
local dropin_dir="/etc/claude-code/managed-settings.d"
local target="$${dropin_dir}/10-coder.json"
if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
echo "Warning: managed_settings is not valid JSON, skipping policy write"
return
fi
if command_exists sudo; then
sudo mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
sudo chmod 0644 "$${target}"
else
mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
chmod 0644 "$${target}"
fi
echo "Wrote Claude Code managed settings to $${target}"
}
function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."
@@ -158,8 +186,6 @@ function configure_standalone_mode() {
echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
@@ -168,8 +194,6 @@ function configure_standalone_mode() {
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
@@ -189,4 +213,5 @@ EOF
install_claude_code_cli
setup_claude_configurations
write_managed_settings
configure_standalone_mode
+22 -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.4"
version = "1.5.0"
agent_id = coder_agent.example.id
}
```
@@ -29,7 +29,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
install_version = "4.106.3"
}
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,12 +78,26 @@ Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
```
### Open a Workspace File
Open a [`.code-workspace`](https://coder.com/docs/code-server/FAQ#how-does-code-server-decide-what-workspace-or-folder-to-open) file instead of a folder. `folder` and `workspace` are mutually exclusive.
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.5.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/project/my.code-workspace"
}
```
### Pass Additional Arguments
You can pass additional command-line arguments to code-server using the `additional_args` variable. For example, to disable workspace trust:
@@ -92,7 +106,7 @@ You can pass additional command-line arguments to code-server using the `additio
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
additional_args = "--disable-workspace-trust"
}
@@ -108,7 +122,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -121,7 +135,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.4.4"
version = "1.5.0"
agent_id = coder_agent.example.id
offline = true
}
@@ -48,3 +48,55 @@ run "url_with_folder_query" {
error_message = "coder_app URL must include encoded folder query param"
}
}
run "url_with_workspace_query" {
command = plan
variables {
agent_id = "foo"
workspace = "/home/coder/project/my.code-workspace"
port = 13337
}
assert {
condition = resource.coder_app.code-server.url == "http://localhost:13337/?workspace=%2Fhome%2Fcoder%2Fproject%2Fmy.code-workspace"
error_message = "coder_app URL must include encoded workspace query param"
}
}
run "url_with_no_target" {
command = plan
variables {
agent_id = "foo"
port = 13337
}
assert {
condition = resource.coder_app.code-server.url == "http://localhost:13337/"
error_message = "coder_app URL must omit query string when neither folder nor workspace is set"
}
}
run "folder_and_workspace_conflict" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder/project"
workspace = "/home/coder/project/my.code-workspace"
}
expect_failures = [
var.workspace
]
}
run "workspace_extension_rejected" {
command = plan
variables {
agent_id = "foo"
workspace = "/home/coder/project/settings.json"
}
expect_failures = [var.workspace]
}
+16 -2
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -56,6 +56,19 @@ variable "folder" {
default = ""
}
variable "workspace" {
type = string
description = "The path to a `.code-workspace` file to open in code-server. Mutually exclusive with `folder`."
default = ""
validation {
condition = var.workspace == "" || endswith(var.workspace, ".code-workspace")
error_message = "workspace must be a path to a .code-workspace file"
}
validation {
condition = var.folder == "" || var.workspace == ""
error_message = "folder and workspace are mutually exclusive; set at most one"
}
}
variable "install_prefix" {
type = string
description = "The prefix to install code-server to."
@@ -173,6 +186,7 @@ resource "coder_script" "code-server" {
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
EXTENSIONS_DIR : var.extensions_dir,
FOLDER : var.folder,
WORKSPACE : var.workspace,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
ADDITIONAL_ARGS : var.additional_args,
})
@@ -195,7 +209,7 @@ resource "coder_app" "code-server" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : var.workspace != "" ? "?workspace=${urlencode(var.workspace)}" : ""}"
icon = "/icon/code.svg"
subdomain = var.subdomain
share = var.share
+20 -6
View File
@@ -122,15 +122,29 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
exit 0
fi
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
RECOMMENDATIONS_FILE=""
RECOMMENDATIONS_QUERY=".recommendations[]"
if [ -n "${WORKSPACE}" ]; then
if [ -f "${WORKSPACE}" ]; then
RECOMMENDATIONS_FILE="${WORKSPACE}"
RECOMMENDATIONS_QUERY=".extensions.recommendations[]?"
else
echo "⚠️ Workspace file ${WORKSPACE} not found, skipping extension recommendations."
fi
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
RECOMMENDATIONS_FILE="$WORKSPACE_DIR/.vscode/extensions.json"
fi
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
if [ -n "$RECOMMENDATIONS_FILE" ]; then
printf "🧩 Installing extensions from %s...\n" "$RECOMMENDATIONS_FILE"
# Use sed to remove single-line comments before parsing with jq
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]')
extensions=$(sed 's|//.*||g' "$RECOMMENDATIONS_FILE" | jq -r "$RECOMMENDATIONS_QUERY")
for extension in $extensions; do
if extension_installed "$extension"; then
continue
+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.4.1"
version = "1.4.2"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.4.2"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.4.2"
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.4.1"
version = "1.4.2"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.4.2"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.1"
version = "1.4.2"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
@@ -1,9 +1,11 @@
import { describe, expect, it } from "bun:test";
import {
findResourceInstance,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
import { readableStreamToText, spawn } from "bun";
describe("dotfiles", async () => {
await runTerraformInit(import.meta.dir);
@@ -34,6 +36,24 @@ describe("dotfiles", async () => {
dotfiles_uri: url,
});
expect(state.outputs.dotfiles_uri.value).toBe(url);
// Run the rendered shell script to verify the shell-side URI
// validation also accepts the URL. The script will fail later
// (no coder binary available), but it must not fail at the
// URI validation step.
const instance = findResourceInstance(state, "coder_script");
const proc = spawn(["bash", "-c", instance.script], {
stdout: "pipe",
stderr: "pipe",
});
const stderr = await readableStreamToText(proc.stderr);
await proc.exited;
expect(stderr).not.toContain(
"ERROR: DOTFILES_URI contains invalid characters",
);
expect(stderr).not.toContain(
"ERROR: DOTFILES_URI must be a valid repository URL",
);
}
});
+1 -1
View File
@@ -9,7 +9,7 @@ DOTFILES_BRANCH="${DOTFILES_BRANCH}"
# 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
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@~-] ]]; then
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
exit 1
fi
+32 -17
View File
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}
@@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
@@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
@@ -185,21 +185,32 @@ module "git-clone" {
}
```
## Git shallow clone
## Extra `git clone` arguments
Limit the clone history to speed-up workspace startup by setting `depth`.
> [!NOTE]
> **Upgrading from v1.x?** The `depth` variable was removed in v2.0.0. Use `extra_args = ["--depth=1"]` instead.
> Do not pass `-b` or `--branch` in `extra_args` when `branch_name` is
> already set (or extracted from the URL). Git silently accepts the last
> `-b` flag, so the two values would conflict.
When `depth` is greater than `0` the module runs `git clone --depth <depth>`.
If not defined, the default, `0`, performs a full clone.
Pass any additional flags through `extra_args` (one element per argument).
This lets you enable anything `git clone` supports without the module having
to expose it explicitly, for example a shallow clone, submodules, parallel
fetches, or partial clones.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
depth = 1
extra_args = [
"--depth=1",
"--recurse-submodules",
"--jobs=8",
"--filter=blob:none",
]
}
```
@@ -212,7 +223,7 @@ This is useful for preparing the environment or validating prerequisites before
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT
@@ -235,7 +246,7 @@ This is useful for running initialization tasks like installing dependencies or
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
post_clone_script = <<-EOT
@@ -248,3 +259,7 @@ module "git-clone" {
EOT
}
```
## Troubleshooting
Logs and scripts for `clone`, `pre_clone`, and `post_clone` are written to `~/.coder-modules/coder/git-clone/<folder_name>/logs/` and `~/.coder-modules/coder/git-clone/<folder_name>/scripts/` respectively.
+191 -22
View File
@@ -1,11 +1,48 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type scriptOutput,
type TerraformState,
} from "~test";
const executeScriptInContainer = async (
state: TerraformState,
image: string,
before?: string,
): Promise<scriptOutput> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
if (before) {
await execContainer(id, ["sh", "-c", before]);
}
const resp = await execContainer(id, ["bash", "-c", instance.script]);
return {
exitCode: resp.exitCode,
stdout: resp.stdout.trim().split("\n"),
stderr: resp.stderr.trim().split("\n"),
};
};
// Drops a fake `git` onto PATH that prints each argv entry on its own line.
// Lets tests prove that arguments (including ones with embedded spaces) reach
// `git clone` as single argv tokens, which the echo line cannot show because
// it joins with spaces.
const installFakeGit = [
"cat > /usr/local/bin/git <<'SHIM'",
"#!/bin/sh",
'for arg in "$@"; do',
' printf "argv:%s\\n" "$arg"',
"done",
"SHIM",
"chmod +x /usr/local/bin/git",
].join("\n");
describe("git-clone", async () => {
await runTerraformInit(import.meta.dir);
@@ -30,12 +67,11 @@ describe("git-clone", async () => {
url: "fake-url",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toEqual([
"Creating directory ~/fake-url...",
"Cloning fake-url to ~/fake-url...",
]);
expect(output.stderr.join(" ")).toContain("fatal");
expect(output.stderr.join(" ")).toContain("fake-url");
expect(output.stdout).toContain("Creating directory /root/fake-url...");
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
expect(output.exitCode).not.toBe(0);
expect(output.stdout.join(" ")).toContain("fatal");
expect(output.stdout.join(" ")).toContain("fake-url");
});
it("repo_dir should match repo name for https", async () => {
@@ -206,10 +242,12 @@ describe("git-clone", async () => {
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
expect(output.stdout).toContain(
"Creating directory /root/repo-tests.log...",
);
expect(output.stdout).toContain(
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
);
});
it("runs with gitlab clone with switch to feat/branch", async () => {
@@ -219,10 +257,12 @@ describe("git-clone", async () => {
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
expect(output.stdout).toContain(
"Creating directory /root/repo-tests.log...",
);
expect(output.stdout).toContain(
"Cloning https://gitlab.com/mike.brew/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
);
});
it("runs with github clone with branch_name set to feat/branch", async () => {
@@ -240,23 +280,25 @@ describe("git-clone", async () => {
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"Creating directory ~/repo-tests.log...",
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
expect(output.stdout).toContain(
"Creating directory /root/repo-tests.log...",
);
expect(output.stdout).toContain(
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
);
});
it("runs post-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script executed'",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
"sh",
"mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
);
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script executed");
@@ -271,6 +313,133 @@ describe("git-clone", async () => {
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script executed");
expect(output.stdout).toContain("Cloning fake-url to ~/fake-url...");
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
});
it("fails when pre-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script failed'; exit 42",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(42);
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script failed");
expect(output.stdout).not.toContain(
"Cloning fake-url to /root/fake-url...",
);
});
it("defaults extra_args to empty", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
installFakeGit,
);
// With no extra_args the only argv tokens should be clone, url, path.
expect(output.stdout.join("\n")).toContain(
["argv:clone", "argv:fake-url", "argv:/root/fake-url"].join("\n"),
);
});
it("passes extra_args to git clone", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
extra_args: JSON.stringify([
"--recurse-submodules",
"--jobs=8",
"--config=user.name=Coder User",
"-c",
"core.sshCommand=ssh -i /tmp/key",
]),
});
const output = await executeScriptInContainer(
state,
"alpine/git",
installFakeGit,
);
expect(output.exitCode).toBe(0);
expect(output.stdout.join("\n")).toContain(
[
"argv:clone",
"argv:--recurse-submodules",
"argv:--jobs=8",
"argv:--config=user.name=Coder User",
"argv:-c",
"argv:core.sshCommand=ssh -i /tmp/key",
"argv:fake-url",
"argv:/root/fake-url",
].join("\n"),
);
});
it("passes extra_args alongside branch_name in the correct order", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
branch_name: "feat/branch",
extra_args: JSON.stringify([
"--recurse-submodules",
"--config=user.name=Coder User",
]),
});
const output = await executeScriptInContainer(
state,
"alpine/git",
installFakeGit,
);
expect(output.exitCode).toBe(0);
expect(output.stdout.join("\n")).toContain(
[
"argv:clone",
"argv:--recurse-submodules",
"argv:--config=user.name=Coder User",
"argv:-b",
"argv:feat/branch",
"argv:fake-url",
"argv:/root/fake-url",
].join("\n"),
);
});
it("writes output to logs/clone.log under module directory", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/git");
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
await execContainer(id, ["bash", "-c", instance.script]);
const log = await execContainer(id, [
"bash",
"-c",
"cat /root/.coder-modules/coder/git-clone/*/logs/clone.log",
]);
expect(log.exitCode).toBe(0);
expect(log.stdout).toContain("Cloning fake-url to /root/fake-url...");
});
it("fails when post-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script failed'; exit 43",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
);
expect(output.exitCode).toBe(43);
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script failed");
});
});
+43 -13
View File
@@ -56,10 +56,10 @@ variable "folder_name" {
default = ""
}
variable "depth" {
description = "If > 0, perform a shallow clone using this depth."
type = number
default = 0
variable "extra_args" {
description = "Extra arguments to pass to `git clone`, one element per argument (e.g. `[\"--recurse-submodules\", \"--jobs=8\", \"--filter=blob:none\"]`)."
type = list(string)
default = []
}
variable "post_clone_script" {
@@ -97,6 +97,30 @@ locals {
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
# Encode the pre_clone_script for passing to the shell script
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
encoded_extra_args = base64encode(join("\n", var.extra_args))
# Module directory paths (matches coder-utils convention)
# Use folder_name so two git-clone instances in the same template get
# separate script and log directories.
module_dir = "$HOME/.coder-modules/coder/git-clone/${local.folder_name}"
scripts_directory = "${local.module_dir}/scripts"
log_directory = "${local.module_dir}/logs"
clone_script_path = "${local.scripts_directory}/clone.sh"
clone_log_path = "${local.log_directory}/clone.log"
pre_clone_log_path = "${local.log_directory}/pre_clone.log"
post_clone_log_path = "${local.log_directory}/post_clone.log"
encoded_clone_script = base64encode(templatefile("${path.module}/run.sh", {
CLONE_PATH = local.clone_path,
REPO_URL = local.clone_url,
BRANCH_NAME = local.branch_name,
EXTRA_ARGS = local.encoded_extra_args,
POST_CLONE_SCRIPT = local.encoded_post_clone_script,
PRE_CLONE_SCRIPT = local.encoded_pre_clone_script,
SCRIPTS_DIR = local.scripts_directory,
PRE_CLONE_LOG_PATH = local.pre_clone_log_path,
POST_CLONE_LOG_PATH = local.post_clone_log_path,
}))
}
output "repo_dir" {
@@ -130,15 +154,21 @@ output "branch_name" {
}
resource "coder_script" "git_clone" {
agent_id = var.agent_id
script = templatefile("${path.module}/run.sh", {
CLONE_PATH = local.clone_path,
REPO_URL : local.clone_url,
BRANCH_NAME : local.branch_name,
DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
})
agent_id = var.agent_id
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p "${local.module_dir}"
mkdir -p "${local.scripts_directory}"
mkdir -p "${local.log_directory}"
echo -n '${local.encoded_clone_script}' | base64 -d > "${local.clone_script_path}"
chmod +x "${local.clone_script_path}"
"${local.clone_script_path}" 2>&1 | tee "${local.clone_log_path}"
EOT
display_name = "Git Clone"
icon = "/icon/git.svg"
run_on_start = true
+24 -21
View File
@@ -1,13 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_URL="${REPO_URL}"
CLONE_PATH="${CLONE_PATH}"
BRANCH_NAME="${BRANCH_NAME}"
# Expand home if it's specified!
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}"
EXTRA_ARGS="${EXTRA_ARGS}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
SCRIPTS_DIR="${SCRIPTS_DIR}"
PRE_CLONE_LOG_PATH="${PRE_CLONE_LOG_PATH}"
POST_CLONE_LOG_PATH="${POST_CLONE_LOG_PATH}"
# Check if the variable is empty...
if [ -z "$REPO_URL" ]; then
@@ -37,11 +42,18 @@ fi
# Run pre-clone script if provided
if [ -n "$PRE_CLONE_SCRIPT" ]; then
echo "Running pre-clone script..."
PRE_CLONE_TMP=$(mktemp)
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP"
chmod +x "$PRE_CLONE_TMP"
$PRE_CLONE_TMP
rm "$PRE_CLONE_TMP"
PRE_CLONE_PATH="$SCRIPTS_DIR/pre_clone.sh"
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_PATH"
chmod +x "$PRE_CLONE_PATH"
"$PRE_CLONE_PATH" 2>&1 | tee "$PRE_CLONE_LOG_PATH"
fi
# Build optional git clone flags
extra_args=()
if [ -n "$EXTRA_ARGS" ]; then
while IFS= read -r arg || [ -n "$arg" ]; do
[ -n "$arg" ] && extra_args+=("$arg")
done < <(echo "$EXTRA_ARGS" | base64 -d)
fi
# Check if the directory is empty
@@ -49,18 +61,10 @@ fi
if [ -z "$(ls -A "$CLONE_PATH")" ]; then
if [ -z "$BRANCH_NAME" ]; then
echo "Cloning $REPO_URL to $CLONE_PATH..."
if [ "$DEPTH" -gt 0 ]; then
git clone --depth "$DEPTH" "$REPO_URL" "$CLONE_PATH"
else
git clone "$REPO_URL" "$CLONE_PATH"
fi
git clone $${extra_args[@]+"$${extra_args[@]}"} "$REPO_URL" "$CLONE_PATH"
else
echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..."
if [ "$DEPTH" -gt 0 ]; then
git clone --depth "$DEPTH" -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
else
git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH"
fi
git clone $${extra_args[@]+"$${extra_args[@]}"} -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
fi
else
echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
@@ -69,10 +73,9 @@ fi
# Run post-clone script if provided
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_PATH="$SCRIPTS_DIR/post_clone.sh"
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_PATH"
chmod +x "$POST_CLONE_PATH"
cd "$CLONE_PATH" || exit
$POST_CLONE_TMP
rm "$POST_CLONE_TMP"
"$POST_CLONE_PATH" 2>&1 | tee "$POST_CLONE_LOG_PATH"
fi
+35
View File
@@ -0,0 +1,35 @@
---
icon: ../../../.icons/coder.svg
sources:
- repo: coder/skills@main
skills:
setup:
display_name: Coder Setup
icon: ../../../.icons/coder.svg
tags: [coder, deployment, configuration]
modules:
display_name: Coder Modules
icon: ../../../.icons/coder-modules.svg
tags: [coder, terraform, modules]
templates:
display_name: Coder Templates
icon: ../../../.icons/coder-templates.svg
tags: [coder, terraform, templates]
---
# Coder Skills
Agent skills maintained by [Coder](https://coder.com) for installing,
configuring, and developing with the Coder platform.
Skills are sourced from [coder/skills](https://github.com/coder/skills)
and served through the registry's API, MCP tools, and
[well-known discovery endpoint](https://agentskills.io/specification).
## Available Skills
| Skill | Description |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |
| [Coder Modules](https://registry.coder.com/skills/coder/modules) | Add or update Coder modules (from registry.coder.com/modules) inside an existing Coder template. Covers IDEs, AI agents, secrets, dev environment tools, and cloud regions. |
| [Coder Templates](https://registry.coder.com/skills/coder/templates) | Author, edit, push, or version a Coder template. Covers starter selection, template anatomy, parameters, validation, push, and first-workspace verification. |