Compare commits

..

13 Commits

Author SHA1 Message Date
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
35C4n0r 99510a1f75 feat(coder/modules/boundary): add agent-firewall module (#840)
## Description

Extracts boundary installation and wrapper logic into a standalone
`coder/agent-firewall` module, decoupling it from `agentapi`.

### Why

Boundary is currently embedded inside `agentapi` (`scripts/boundary.sh`)
and duplicated in `claude-code`. This couples network isolation to the
AI/Tasks stack, but boundary is a general-purpose primitive — users
running a plain agent with no agentapi or tasks should be able to use it
too.

### What this adds

`registry/coder/modules/agent-firewall/` — a new first-class module
that:

* Installs boundary via one of three strategies:
  1. `coder boundary` subcommand (default, zero-install)
  2. Direct binary from release (`use_agent_firewall_directly = true`)
  3. Compiled from source (`compile_agent_firewall_from_source = true`)
* Ships a comprehensive [default allowlist
config](registry/coder/modules/agent-firewall/config.yaml.tftpl)
(Anthropic, OpenAI, VCS, package managers, cloud platforms, etc.)
* Auto-fills the Coder deployment domain via
`data.coder_workspace.me.access_url`
* Supports inline config (`agent_firewall_config`) or external file
(`agent_firewall_config_path`), mutually exclusive with cross-variable
validation
* Creates a wrapper script at
`$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
* Strips `CAP_NET_ADMIN` from the coder binary (copies to
`coder-no-caps`) to allow execution inside network namespaces without
`sys_admin`
* Supports `pre_install_script` / `post_install_script` hooks
* Exposes `agent_firewall_wrapper_path`, `agent_firewall_config_path`,
and `scripts` outputs for script coordination
* No env vars exported — everything is output-only

### Usage

```tf
module "agent-firewall" {
  source   = "registry.coder.com/coder/agent-firewall/coder"
  version  = "0.0.1"
  agent_id = coder_agent.main.id
}
```

Works standalone with any agent — no agentapi dependency required.

### Testing

* 8 Terraform plan tests (`agent-firewall.tftest.hcl`): default outputs,
compile from source, use directly, custom hooks, custom module
directory, inline config, external config path, mutual exclusion
validation
* TypeScript integration tests (`main.test.ts`): state verification,
coder subcommand happy path, inline config, config path skip, custom
hooks, env var absence, wrapper execution, idempotent installation

## Type of Change

- [X] New module

## Module Information

**Path:** `registry/coder/modules/agent-firewall` <br>**New version:**
`v0.0.1` <br>**Breaking change:** No

## Related Issues

Closes coder/registry#844

🤖 Generated by Coder Agents

---------

Co-authored-by: Jay Kumar <jay.kumar@coder.com>
2026-05-10 06:23:37 +00:00
ikkz 297b07190f feat(git-clone): add pre_clone_script parameter (#887)
## Summary

Add `pre_clone_script` parameter to the git-clone module, allowing users
to run custom scripts before cloning a repository.

## Use Case

This solves SSH host key verification issues (e.g., "Host key
verification failed") by enabling users to configure SSH settings before
the clone operation, such as adding known hosts or setting
`StrictHostKeyChecking no`.

```tf
module "git-clone" {
  count            = data.coder_workspace.me.start_count
  source           = "registry.coder.com/coder/git-clone/coder"
  version          = "1.3.0"
  agent_id         = coder_agent.example.id
  url              = "git@github.com:org/repo.git"
  pre_clone_script = <<-EOT
    #!/bin/bash
    mkdir -p ~/.ssh
    echo -e "Host github.com\n    StrictHostKeyChecking no\n" > ~/.ssh/config
    chmod 600 ~/.ssh/config
  EOT
}
```

Ref:
https://discord.com/channels/747933592273027093/1447777180695396452/1447777180695396452

## Type of Change

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

## Module Information

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

## Testing & Validation

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

Co-authored-by: DevCats <christofer@coder.com>
2026-05-09 17:00:43 -05:00
Harsh Singh Panwar bce0897099 Fix(gemini): the Coder MCP server configuration (#882)
## Description

Fixed the Coder MCP server configuration

* Added the full path to the coder binary for Gemini
* Removed unnecessary configuration fields

<img width="1365" height="715" alt="Screenshot 2026-05-04 120727"
src="https://github.com/user-attachments/assets/35cdb18f-c4a5-437d-8ad6-38134104e5e6"
/>
<img width="1365" height="717" alt="Screenshot 2026-05-04 120836"
src="https://github.com/user-attachments/assets/bdce543e-dd7f-4122-b356-896d08e1fd3f"
/>

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

fix: #881
2026-05-06 13:50:20 -05:00
DevCats 6b8d89daba fix(registry/coder-labs/modules/codex): align variable names with claude-code v5 (#885)
Aligns codex module variable names with the claude-code v5 conventions
established in #861 and #879.

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

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

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

---
*This PR was authored by Coder Agents.*
2026-05-05 12:31:09 -05:00
36 changed files with 2153 additions and 123 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@7b04f660f4ee4f048d18fd341887cf28dfbedfe2 # v1.46.3
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])?$`)
+6 -11
View File
@@ -50,16 +50,16 @@ variable "sessions" {
default = ["default"]
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
resource "coder_script" "tmux" {
agent_id = var.agent_id
display_name = "tmux"
icon = "/icon/terminal.svg"
install_script = templatefile("${path.module}/scripts/run.sh", {
TMUX_CONFIG = base64encode(var.tmux_config)
script = templatefile("${path.module}/scripts/run.sh", {
TMUX_CONFIG = base64encode(var.tmux_config)
SAVE_INTERVAL = var.save_interval
})
module_directory = "$HOME/.coder-modules/anomaly/tmux"
run_on_start = true
run_on_stop = false
}
resource "coder_app" "tmux_sessions" {
@@ -76,8 +76,3 @@ resource "coder_app" "tmux_sessions" {
SESSION_NAME = each.value
})
}
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
}
+1 -1
View File
@@ -101,7 +101,7 @@ module "codex" {
preferred_auth_method = "apikey"
EOT
additional_mcp_servers = <<-EOT
mcp = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
@@ -246,7 +246,7 @@ describe("codex", async () => {
].join("\n");
const { id, scripts } = await setup({
moduleVariables: {
additional_mcp_servers: additional,
mcp: additional,
},
});
await runScripts(id, scripts);
+6 -6
View File
@@ -50,8 +50,8 @@ variable "install_codex" {
variable "codex_version" {
type = string
description = "The version of Codex to install. Empty string installs the latest available version."
default = ""
description = "The version of Codex to install."
default = "latest"
}
variable "openai_api_key" {
@@ -75,16 +75,16 @@ variable "base_config_toml" {
trust_level = "trusted"
When non-empty, the value is written verbatim as the base of config.toml;
additional_mcp_servers and AI Gateway sections are still appended after it.
mcp and AI Gateway sections are still appended after it.
Note: model_reasoning_effort and workdir trust are only applied in the
default config. Include them in your custom config if needed.
EOT
default = ""
}
variable "additional_mcp_servers" {
variable "mcp" {
type = string
description = "Additional MCP servers configuration in TOML format."
description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml."
default = ""
}
@@ -140,7 +140,7 @@ locals {
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
ARG_ADDITIONAL_MCP_SERVERS = var.additional_mcp_servers != "" ? base64encode(var.additional_mcp_servers) : ""
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort
@@ -12,7 +12,7 @@ ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n '${ARG_ADDITIONAL_MCP_SERVERS}' | base64 -d)
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
@@ -150,9 +150,9 @@ function populate_config_toml() {
write_minimal_default_config "$${config_path}"
fi
if [ -n "$${ARG_ADDITIONAL_MCP_SERVERS}" ]; then
printf "Adding additional MCP servers\n"
echo "$${ARG_ADDITIONAL_MCP_SERVERS}" >> "$${config_path}"
if [ -n "$${ARG_MCP}" ]; then
printf "Adding MCP servers\n"
echo "$${ARG_MCP}" >> "$${config_path}"
fi
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then
+20 -4
View File
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.0"
version = "3.0.1"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.0"
version = "3.0.1"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.0"
version = "3.0.1"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
@@ -105,6 +105,22 @@ module "gemini" {
You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER.
EOT
pre_install_script = <<-EOT
#!/bin/bash
set -e
echo "Installing Node.js via NodeSource..."
sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
sudo apt-get install -y nodejs
echo "Node version: $(node -v)"
echo "npm version: $(npm -v)"
echo "Node install complete."
EOT
}
```
@@ -118,7 +134,7 @@ For enterprise users who prefer Google's Vertex AI platform:
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.0"
version = "3.0.1"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
+2 -8
View File
@@ -148,22 +148,16 @@ locals {
base_extensions = <<-EOT
{
"coder": {
"command": "coder",
"args": [
"exp",
"mcp",
"server"
],
"command": "coder",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"enabled": true,
"env": {
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
},
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
}
}
}
EOT
@@ -17,6 +17,7 @@ echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
printf "install: %s\n" "$ARG_INSTALL"
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
echo "--------------------------------"
set +o nounset
@@ -140,6 +141,25 @@ function add_system_prompt_if_exists() {
fi
}
function patch_coder_mcp_command() {
CODER_BIN=$(which coder)
SETTINGS_PATH="$HOME/.gemini/settings.json"
if [ -z "$CODER_BIN" ]; then
printf "Warning: could not find coder binary, MCP command path not patched.\n"
return
fi
printf "Patching coder MCP command path to: %s\n" "$CODER_BIN"
TMP_SETTINGS=$(mktemp)
jq --arg bin "$CODER_BIN" \
'.mcpServers.coder.command = $bin' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "Patch complete.\n"
}
function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
@@ -149,4 +169,5 @@ function configure_mcp() {
install_gemini
populate_settings_json
add_system_prompt_if_exists
patch_coder_mcp_command
configure_mcp
@@ -0,0 +1,146 @@
---
display_name: Agent Firewall
description: Configures agent-firewall for network isolation in Coder workspaces
icon: ../../../../.icons/coder.svg
verified: true
tags: [agent-firewall, ai, agents, firewall, boundary]
---
# Agent Firewall
Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces.
This module:
- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source)
- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable)
- Provides the wrapper path, config path, and script names via outputs
- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
```
## Examples
Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation.
### With Claude Code
Use agent-firewall alongside the `claude-code` module to run Claude in a
network-isolated environment.
#### As an automated task
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_script" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude (Agent Firewall)"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
coder exp sync want claude-agent-firewall \
${join(" ", module.agent-firewall.scripts)} \
${join(" ", module.claude-code.scripts)}
coder exp sync start claude-agent-firewall
"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder"
EOT
}
```
#### As a Coder app
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_app" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude Code"
slug = "claude-code"
command = <<-EOT
#!/bin/bash
set -e
exec tmux new-session -A -s claude-code \
'"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude'
EOT
}
```
## Configuration
The module ships with a comprehensive default config based on the
[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services,
OpenAI services, version control, package managers, container registries,
cloud platforms, and common development tools.
The Coder deployment domain is automatically added to the allowlist using
`data.coder_workspace.me.access_url`.
By default the config is written to
`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can
access the resolved path via the `agent_firewall_config_path` output. Override
it in two ways:
### Inline config
Pass the full YAML content directly:
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config = <<-YAML
allowlist:
- domain=your-deployment.coder.com
- domain=api.anthropic.com
- domain=api.openai.com
log_dir: /tmp/agent_firewall_logs
proxy_port: 8087
log_level: warn
YAML
}
```
### External config file
Point to an existing config file in the workspace. The module will not
write any config and the `agent_firewall_config_path` output will point to
your path. The file must exist on disk before agent-firewall starts.
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml"
}
```
> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually
> exclusive, setting both produces a validation error.
See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall)
for the full config reference.
## References
- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall)
@@ -0,0 +1,157 @@
# Test for agent-firewall module
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "test-agent-id"
}
# Verify the agent_firewall_wrapper_path output
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
# Verify agent_firewall_config_path output defaults to the managed path
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should default to managed config path"
}
# Verify the scripts output contains the install script name
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_compile_from_source" {
command = plan
variables {
agent_id = "test-agent-id"
compile_agent_firewall_from_source = true
agent_firewall_version = "main"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_use_directly" {
command = plan
variables {
agent_id = "test-agent-id"
use_agent_firewall_directly = true
agent_firewall_version = "latest"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_custom_hooks" {
command = plan
variables {
agent_id = "test-agent-id"
pre_install_script = "echo 'Before install'"
post_install_script = "echo 'After install'"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
# Verify pre and post install script names are set
assert {
condition = contains(output.scripts, "coder-agent-firewall-pre_install_script")
error_message = "scripts should contain the pre_install script name"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-post_install_script")
error_message = "scripts should contain the post_install script name"
}
}
run "plan_with_custom_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/custom/agent-firewall"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should use custom module directory"
}
# Config path should also follow the module directory
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should use custom module directory"
}
}
run "plan_with_inline_config" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n"
}
# Inline config should still point to the managed path.
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should point to managed config path"
}
}
run "plan_with_config_path" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config_path = "/workspace/my-boundary-config.yaml"
}
# agent_firewall_config_path output should point to the user-provided path.
assert {
condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml"
error_message = "agent_firewall_config_path output should point to user-provided path"
}
}
run "plan_with_both_configs_should_fail" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist: []"
agent_firewall_config_path = "/workspace/config.yaml"
}
expect_failures = [
var.agent_firewall_config,
]
}
@@ -0,0 +1,218 @@
allowlist:
- domain=${CODER_DOMAIN}
# Anthropic Services
- domain=api.anthropic.com
- domain=statsig.anthropic.com
- domain=claude.ai
# OpenAI Services
- domain=api.openai.com
- domain=platform.openai.com
- domain=openai.com
- domain=chatgpt.com
- domain=*.oaiusercontent.com
- domain=*.oaistatic.com
# Version Control
- domain=github.com
- domain=www.github.com
- domain=api.github.com
- domain=raw.githubusercontent.com
- domain=objects.githubusercontent.com
- domain=codeload.github.com
- domain=avatars.githubusercontent.com
- domain=camo.githubusercontent.com
- domain=gist.github.com
- domain=gitlab.com
- domain=www.gitlab.com
- domain=registry.gitlab.com
- domain=bitbucket.org
- domain=www.bitbucket.org
- domain=api.bitbucket.org
# Container Registries
- domain=registry-1.docker.io
- domain=auth.docker.io
- domain=index.docker.io
- domain=hub.docker.com
- domain=www.docker.com
- domain=production.cloudflare.docker.com
- domain=download.docker.com
- domain=*.gcr.io
- domain=ghcr.io
- domain=mcr.microsoft.com
- domain=*.data.mcr.microsoft.com
# Cloud Platforms
- domain=cloud.google.com
- domain=accounts.google.com
- domain=gcloud.google.com
- domain=*.googleapis.com
- domain=storage.googleapis.com
- domain=compute.googleapis.com
- domain=container.googleapis.com
- domain=azure.com
- domain=portal.azure.com
- domain=microsoft.com
- domain=www.microsoft.com
- domain=*.microsoftonline.com
- domain=packages.microsoft.com
- domain=dotnet.microsoft.com
- domain=dot.net
- domain=visualstudio.com
- domain=dev.azure.com
- domain=oracle.com
- domain=www.oracle.com
- domain=java.com
- domain=www.java.com
- domain=java.net
- domain=www.java.net
- domain=download.oracle.com
- domain=yum.oracle.com
# Package Managers - JavaScript/Node
- domain=registry.npmjs.org
- domain=www.npmjs.com
- domain=www.npmjs.org
- domain=npmjs.com
- domain=npmjs.org
- domain=yarnpkg.com
- domain=registry.yarnpkg.com
# Package Managers - Python
- domain=pypi.org
- domain=www.pypi.org
- domain=files.pythonhosted.org
- domain=pythonhosted.org
- domain=test.pypi.org
- domain=pypi.python.org
- domain=pypa.io
- domain=www.pypa.io
# Package Managers - Ruby
- domain=rubygems.org
- domain=www.rubygems.org
- domain=api.rubygems.org
- domain=index.rubygems.org
- domain=ruby-lang.org
- domain=www.ruby-lang.org
- domain=rubyforge.org
- domain=www.rubyforge.org
- domain=rubyonrails.org
- domain=www.rubyonrails.org
- domain=rvm.io
- domain=get.rvm.io
# Package Managers - Rust
- domain=crates.io
- domain=www.crates.io
- domain=static.crates.io
- domain=rustup.rs
- domain=static.rust-lang.org
- domain=www.rust-lang.org
# Package Managers - Go
- domain=proxy.golang.org
- domain=sum.golang.org
- domain=index.golang.org
- domain=golang.org
- domain=www.golang.org
- domain=go.dev
- domain=dl.google.com
- domain=goproxy.io
- domain=pkg.go.dev
# Package Managers - JVM
- domain=maven.org
- domain=repo.maven.org
- domain=central.maven.org
- domain=repo1.maven.org
- domain=jcenter.bintray.com
- domain=gradle.org
- domain=www.gradle.org
- domain=services.gradle.org
- domain=spring.io
- domain=repo.spring.io
# Package Managers - Other Languages
- domain=packagist.org
- domain=www.packagist.org
- domain=repo.packagist.org
- domain=nuget.org
- domain=www.nuget.org
- domain=api.nuget.org
- domain=pub.dev
- domain=api.pub.dev
- domain=hex.pm
- domain=www.hex.pm
- domain=cpan.org
- domain=www.cpan.org
- domain=metacpan.org
- domain=www.metacpan.org
- domain=api.metacpan.org
- domain=cocoapods.org
- domain=www.cocoapods.org
- domain=cdn.cocoapods.org
- domain=haskell.org
- domain=www.haskell.org
- domain=hackage.haskell.org
- domain=swift.org
- domain=www.swift.org
# Linux Distributions
- domain=archive.ubuntu.com
- domain=security.ubuntu.com
- domain=ubuntu.com
- domain=www.ubuntu.com
- domain=*.ubuntu.com
- domain=ppa.launchpad.net
- domain=launchpad.net
- domain=www.launchpad.net
# Development Tools & Platforms
- domain=dl.k8s.io
- domain=pkgs.k8s.io
- domain=k8s.io
- domain=www.k8s.io
- domain=releases.hashicorp.com
- domain=apt.releases.hashicorp.com
- domain=rpm.releases.hashicorp.com
- domain=archive.releases.hashicorp.com
- domain=hashicorp.com
- domain=www.hashicorp.com
- domain=repo.anaconda.com
- domain=conda.anaconda.org
- domain=anaconda.org
- domain=www.anaconda.com
- domain=anaconda.com
- domain=continuum.io
- domain=apache.org
- domain=www.apache.org
- domain=archive.apache.org
- domain=downloads.apache.org
- domain=eclipse.org
- domain=www.eclipse.org
- domain=download.eclipse.org
- domain=nodejs.org
- domain=www.nodejs.org
# Cloud Services & Monitoring
- domain=statsig.com
- domain=www.statsig.com
- domain=api.statsig.com
- domain=*.sentry.io
# Content Delivery & Mirrors
- domain=*.sourceforge.net
- domain=packagecloud.io
- domain=*.packagecloud.io
# Schema & Configuration
- domain=json-schema.org
- domain=www.json-schema.org
- domain=json.schemastore.org
- domain=www.schemastore.org
log_dir: ${BOUNDARY_LOG_DIR}
log_level: warn
proxy_port: 8087
@@ -0,0 +1,376 @@
import {
test,
afterEach,
describe,
setDefaultTimeout,
beforeAll,
expect,
} from "bun:test";
import {
execContainer,
readFileContainer,
runTerraformInit,
runTerraformApply,
testRequiredVariables,
runContainer,
removeContainer,
} from "~test";
import {
loadTestFile,
writeExecutable,
execModuleScript,
extractCoderEnvVars,
} from "../agentapi/test-util";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
interface SetupProps {
moduleVariables?: Record<string, string>;
skipCoderMock?: boolean;
}
const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall";
const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`;
const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`;
const setup = async (
props?: SetupProps,
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...props?.moduleVariables,
});
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer("codercom/enterprise-node:latest");
registerCleanup(async () => {
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]);
// Create a mock coder binary with boundary subcommand and exp sync support
if (!props?.skipCoderMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: await loadTestFile(import.meta.dir, "coder-mock.sh"),
});
}
// Extract ALL coder_scripts from the state (coder-utils creates multiple)
const allScripts = state.resources
.filter((r) => r.type === "coder_script")
.map((r) => ({
name: r.name,
script: r.instances[0].attributes.script as string,
}));
// Run scripts in lifecycle order
const executionOrder = [
"pre_install_script",
"install_script",
"post_install_script",
];
const orderedScripts = executionOrder
.map((name) => allScripts.find((s) => s.name === name))
.filter((s): s is NonNullable<typeof s> => s != null);
// Write each script individually and create a combined runner
const scriptPaths: string[] = [];
for (const s of orderedScripts) {
const scriptPath = `/home/coder/${s.name}.sh`;
await writeExecutable({
containerId: id,
filePath: scriptPath,
content: s.script,
});
scriptPaths.push(scriptPath);
}
const combinedScript = [
"#!/bin/bash",
"set -o errexit",
"set -o pipefail",
...scriptPaths.map((p) => `bash "${p}"`),
].join("\n");
await writeExecutable({
containerId: id,
filePath: "/home/coder/script.sh",
content: combinedScript,
});
return { id, coderEnvVars };
};
setDefaultTimeout(60 * 1000);
describe("agent-firewall", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
});
test("terraform-state-basic", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
});
const resources = state.resources;
// No coder_env resources should exist
const envResources = resources.filter((r) => r.type === "coder_env");
expect(envResources).toHaveLength(0);
// Verify no env vars are exported
const coderEnvVars = extractCoderEnvVars(state);
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
// Verify agent_firewall_config_path output
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
// Verify agent_firewall_wrapper_path output
expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh",
);
// Verify scripts output contains install script
const scripts = state.outputs["scripts"]?.value as string[];
expect(scripts).toContain("coder-agent-firewall-install_script");
});
test("terraform-state-custom-module-directory", async () => {
const customDir = "$HOME/.coder-modules/custom/agent-firewall";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: customDir,
});
// Verify output uses custom dir
const outputs = state.outputs;
expect(outputs["agent_firewall_wrapper_path"]?.value).toBe(
`${customDir}/scripts/agent-firewall-wrapper.sh`,
);
// Config path follows module directory
expect(outputs["agent_firewall_config_path"]?.value).toBe(
`${customDir}/config/config.yaml`,
);
});
test("terraform-state-inline-config", async () => {
const inlineConfig =
"allowlist:\n - domain=example.com\nlog_level: debug\n";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config: inlineConfig,
});
// Inline config still writes to the managed path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
});
test("terraform-state-config-path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config_path: "/workspace/my-config.yaml",
});
// agent_firewall_config_path output should point to the user-provided path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"/workspace/my-config.yaml",
);
});
test("happy-path-coder-subcommand", async () => {
const { id } = await setup();
await execModuleScript(id);
// Verify the wrapper script was created
const wrapperContent = await readFileContainer(id, WRAPPER_PATH);
expect(wrapperContent).toContain("#!/usr/bin/env bash");
expect(wrapperContent).toContain("coder-no-caps");
expect(wrapperContent).toContain("boundary");
// Verify the wrapper script is executable
const statResult = await execContainer(id, [
"stat",
"-c",
"%a",
WRAPPER_PATH,
]);
expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/);
// Verify coder-no-caps binary was created
const coderNoCapsResult = await execContainer(id, [
"test",
"-f",
`${MODULE_DIR}/scripts/coder-no-caps`,
]);
expect(coderNoCapsResult.exitCode).toBe(0);
// Verify default boundary config was written inside module directory
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("allowlist:");
expect(configContent).toContain("domain=api.anthropic.com");
expect(configContent).toContain("domain=api.openai.com");
expect(configContent).toContain("proxy_port: 8087");
// Verify Coder domain was auto-filled from data.coder_workspace.me
// (the placeholder should be replaced with the actual deployment domain).
expect(configContent).not.toContain("domain=your-deployment.coder.com");
// Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME).
expect(configContent).toContain("log_dir: /home/coder/");
expect(configContent).not.toContain("$HOME");
// Check install log
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("Using coder boundary subcommand");
expect(installLog).toContain("Boundary config written to");
expect(installLog).toContain("boundary wrapper configured");
});
test("inline-config-written", async () => {
const customConfig =
"allowlist:\n - domain=custom.example.com\nlog_level: info\n";
const { id } = await setup({
moduleVariables: {
agent_firewall_config: customConfig,
},
});
await execModuleScript(id);
// Verify the inline config was written
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("domain=custom.example.com");
expect(configContent).toContain("log_level: info");
});
test("config-path-skips-write", async () => {
const { id } = await setup({
moduleVariables: {
agent_firewall_config_path: "/workspace/external-config.yaml",
},
});
await execModuleScript(id);
// Verify NO config was written to the default path
const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]);
expect(checkResult.exitCode).not.toBe(0);
// Check install log confirms skip
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain(
"Using external boundary config, skipping config write",
);
});
// Note: Tests for use_agent_firewall_directly and
// compile_agent_firewall_from_source are skipped because they require
// network access (downloading boundary) or compilation which are too
// slow for unit tests. These modes are tested manually.
test("custom-hooks", async () => {
const preInstallMarker = "pre-install-executed";
const postInstallMarker = "post-install-executed";
const { id } = await setup({
moduleVariables: {
pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`,
post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`,
},
});
await execModuleScript(id);
// Verify pre-install script ran
const preInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/pre_install.log`,
);
expect(preInstallLog).toContain(preInstallMarker);
// Verify post-install script ran
const postInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/post_install.log`,
);
expect(postInstallLog).toContain(postInstallMarker);
// Verify main install still ran
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("boundary wrapper configured");
});
test("no-env-vars", async () => {
const { coderEnvVars } = await setup();
// No env vars should be exported by this module.
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
});
test("wrapper-script-execution", async () => {
const { id } = await setup();
await execModuleScript(id);
// Try executing the wrapper script with a command
const wrapperResult = await execContainer(id, [
"bash",
"-c",
`${WRAPPER_PATH} echo boundary-test`,
]);
// The wrapper passes the command directly to the boundary command
expect(wrapperResult.stdout).toContain("boundary-test");
});
test("installation-idempotency", async () => {
const { id } = await setup();
// Run the installation twice
await execModuleScript(id);
const firstInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
// Run again
const secondRun = await execModuleScript(id);
expect(secondRun.exitCode).toBe(0);
// Both runs should succeed
expect(firstInstallLog).toContain("boundary wrapper configured");
});
});
@@ -0,0 +1,128 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
data "coder_workspace" "me" {}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "agent_firewall_version" {
type = string
description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_agent_firewall_from_source" {
type = bool
description = "Whether to compile agent-firewall from source instead of using the official install script."
default = false
}
variable "use_agent_firewall_directly" {
type = bool
description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release."
default = false
}
variable "agent_firewall_config" {
type = string
description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path."
default = null
validation {
condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null)
error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set."
}
}
variable "agent_firewall_config_path" {
type = string
description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config."
default = null
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing agent-firewall."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing agent-firewall."
default = null
}
variable "module_directory" {
type = string
description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall."
default = "$HOME/.coder-modules/coder/agent-firewall"
}
locals {
boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh"
# Extract domain from the Coder access URL for the default config
# allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com").
coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
# Config handling: resolve which config content to write and where
# agent_firewall_config_path output points to.
default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", {
CODER_DOMAIN = local.coder_domain
BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs"
})
boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config
boundary_config_dir = "${var.module_directory}/config"
boundary_config_file_path = "${local.boundary_config_dir}/config.yaml"
effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path
write_boundary_config = var.agent_firewall_config_path == null
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
BOUNDARY_VERSION = var.agent_firewall_version
COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source)
USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly)
MODULE_DIR = var.module_directory
BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path
WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config)
BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : ""
BOUNDARY_CONFIG_DIR = local.boundary_config_dir
BOUNDARY_CONFIG_FILE = local.boundary_config_file_path
})
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
display_name_prefix = "Agent Firewall"
module_directory = var.module_directory
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
}
output "agent_firewall_wrapper_path" {
description = "Path to the agent-firewall wrapper script."
value = local.boundary_wrapper_path
}
output "agent_firewall_config_path" {
description = "Effective path to the agent-firewall config file."
value = local.effective_boundary_config_path
}
output "scripts" {
description = "List of script names for coder exp sync coordination."
value = module.coder_utils.scripts
}
@@ -0,0 +1,131 @@
#!/bin/bash
# Sets up boundary for network isolation in Coder workspaces.
set -euo pipefail
BOUNDARY_VERSION='${BOUNDARY_VERSION}'
COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}'
USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}'
MODULE_DIR="${MODULE_DIR}"
BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}"
WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}'
BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g")
BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}"
BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}"
printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}"
printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}"
printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}"
printf "MODULE_DIR: %s\n" "$${MODULE_DIR}"
printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}"
printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}"
printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}"
printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}"
validate_boundary_subcommand() {
if ! command -v coder > /dev/null 2>&1; then
echo "Error: 'coder' command not found. boundary cannot be enabled." >&2
exit 1
fi
local output
echo "Checking for license"
if ! output=$(coder boundary 2>&1); then
if echo "$${output}" | grep -qi "license is not entitled"; then
echo "Error: your Coder deployment is not licensed for the boundary feature." >&2
echo "$${output}" >&2
echo "" >&2
exit 1
fi
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then
echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [[ -d boundary ]]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "$${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Write boundary config file if the module is responsible for it.
write_boundary_config() {
if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then
echo "Using external boundary config, skipping config write."
return 0
fi
mkdir -p "$${BOUNDARY_CONFIG_DIR}"
echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}"
echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}"
}
# Set up boundary: install, write config, create wrapper script.
setup_boundary() {
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Write boundary config
write_boundary_config
# Ensure the wrapper script directory exists.
mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")"
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
# Use boundary binary directly (from compilation or release installation)
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps"
if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then
echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2
exit 1
fi
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)"
exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@"
WRAPPER_EOF
fi
chmod +x "$${BOUNDARY_WRAPPER_PATH}"
echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}"
}
setup_boundary
@@ -0,0 +1,38 @@
#!/bin/bash
# Mock coder command for testing boundary module
# Handles: coder boundary [--help | <command>]
# Handles: coder exp sync [want|start|complete] (no-op for testing)
# Handle exp sync commands (no-op for testing)
if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then
exit 0
fi
if [[ "$1" == "boundary" ]]; then
shift
# Handle --help flag
if [[ "$1" == "--help" ]]; then
cat << 'EOF'
boundary - Run commands in network isolation
Usage:
coder boundary [flags] -- <command> [args...]
Examples:
coder boundary -- curl https://example.com
coder boundary -- npm install
Flags:
-h, --help help for boundary
EOF
exit 0
fi
# Execute the remaining arguments as a command
exec "$@"
fi
echo "Mock coder: Unknown command: $*"
exit 1
+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
+54 -16
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.2.3"
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.2.3"
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.2.3"
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.2.3"
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.2.3"
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.2.3"
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.2.3"
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.2.3"
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.2.3"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
@@ -185,21 +185,55 @@ 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.2.3"
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",
]
}
```
## Pre-clone script
Run a custom script before cloning the repository by setting the `pre_clone_script` variable.
This is useful for preparing the environment or validating prerequisites before cloning.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT
#!/bin/bash
echo "Preparing to clone repository..."
# Check prerequisites
command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; }
# Set up environment
export NODE_ENV=development
EOT
}
```
@@ -212,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.2.3"
version = "2.0.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
post_clone_script = <<-EOT
@@ -225,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.
+202 -21
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,25 +280,166 @@ 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");
});
it("runs pre-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script executed'",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script executed");
expect(output.stdout).toContain("Cloning fake-url to /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");
});
});
+51 -12
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" {
@@ -68,6 +68,12 @@ variable "post_clone_script" {
default = null
}
variable "pre_clone_script" {
description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists."
type = string
default = null
}
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -89,6 +95,32 @@ locals {
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
# Encode the post_clone_script for passing to the shell script
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
# Encode the pre_clone_script for passing to the shell script
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
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" {
@@ -122,14 +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,
})
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
+30 -16
View File
@@ -1,12 +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
@@ -33,23 +39,32 @@ if [ ! -d "$CLONE_PATH" ]; then
mkdir -p "$CLONE_PATH"
fi
# Run pre-clone script if provided
if [ -n "$PRE_CLONE_SCRIPT" ]; then
echo "Running pre-clone script..."
PRE_CLONE_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
# and if it is, clone the repo, otherwise skip cloning
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!"
@@ -58,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. |