Compare commits

...

29 Commits

Author SHA1 Message Date
DevCats 11a64e9b08 Merge branch 'main' into feat/nodejs-pre-post-install-scripts 2026-03-20 08:11:52 -05:00
blink-so[bot] 0fd0d08d2f fix: terraform fmt alignment in coder_script.nodejs resource 2026-03-19 18:02:37 +00:00
blink-so[bot] 817238ea64 refactor(nodejs): use base64 encoding and coder exp sync for pre/post install scripts
- Base64 encode pre/post install scripts to safely handle special characters
- Use separate coder_script resources for pre_install, install, and post_install
- Add coder exp sync want/start/complete for execution ordering
- Base64 encode the main install script (run.sh) via templatefile + base64encode
- Revert run.sh to original (no pre/post install handling)
- Add sync name outputs for cross-module dependency coordination
- Update README with cross-module coordination documentation
- Add output assertions to tests
2026-03-19 17:59:22 +00:00
blink-so[bot] d0ef879e5f revert: restore unrelated comment in main.test.ts 2026-03-19 13:24:41 +00:00
blink-so[bot] 872fdbcf35 fix: bump version to 1.1.0 (minor) for new input variables 2026-03-19 13:23:26 +00:00
35C4n0r ce50e52fc5 feat(coder-labs/modules/codex): update default configuration to use model providers instead of profiles (#806)
## Description
- update default configuration to use model providers instead of
profiles

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v4.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

<!-- Link related issues or write "None" if not applicable -->
2026-03-18 11:39:59 +05:30
Hugo Dutka 6940774628 feat: add the portabledesktop module (#805)
## Description
Add a module to install https://github.com/coder/portabledesktop in a
workspace. This will be required for the virtual desktop feature in
Coder Agents.
## Type of Change
- [x] New module
- [ ] New template
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other
## Module Information
**Path:** `registry/coder/modules/portabledesktop`
**New version:** `v1.0.0`
**Breaking change:** [ ] Yes [x] No
## Testing & Validation
- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally
## Related Issues
None
2026-03-17 10:07:35 +01:00
dependabot[bot] 85c51816f9 chore(deps): bump the github-actions group with 3 updates (#804)
Bumps the github-actions group with 3 updates:
[dorny/paths-filter](https://github.com/dorny/paths-filter),
[coder/coder](https://github.com/coder/coder) and
[oven-sh/setup-bun](https://github.com/oven-sh/setup-bun).

Updates `dorny/paths-filter` from 3.0.2 to 4.0.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/releases">dorny/paths-filter's
releases</a>.</em></p>
<blockquote>
<h2>v4.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>feat: update action runtime to node24 by <a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
in <a
href="https://redirect.github.com/dorny/paths-filter/pull/294">dorny/paths-filter#294</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
made their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/294">dorny/paths-filter#294</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/dorny/paths-filter/compare/v3.0.3...v4.0.0">https://github.com/dorny/paths-filter/compare/v3.0.3...v4.0.0</a></p>
<h2>v3.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Add missing predicate-quantifier by <a
href="https://github.com/wardpeet"><code>@​wardpeet</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/279">dorny/paths-filter#279</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/wardpeet"><code>@​wardpeet</code></a>
made their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/279">dorny/paths-filter#279</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/dorny/paths-filter/compare/v3...v3.0.3">https://github.com/dorny/paths-filter/compare/v3...v3.0.3</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md">dorny/paths-filter's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v4.0.0</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/294">Update
action runtime to node24</a></li>
</ul>
<h2>v3.0.3</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/279">Add
missing predicate-quantifier</a></li>
</ul>
<h2>v3.0.2</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/224">Add
config parameter for predicate quantifier</a></li>
</ul>
<h2>v3.0.1</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/133">Compare
base and ref when token is empty</a></li>
</ul>
<h2>v3.0.0</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/210">Update to
Node.js 20</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/215">Update
all dependencies</a></li>
</ul>
<h2>v2.11.1</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/167">Update
<code>@​actions/core</code> to v1.10.0 - Fixes warning about deprecated
set-output</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/168">Document
need for pull-requests: read permission</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/164">Updating
to actions/checkout@v3</a></li>
</ul>
<h2>v2.11.0</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/157">Set
list-files input parameter as not required</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/161">Update
Node.js</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/162">Fix
incorrect handling of Unicode characters in exec()</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/163">Use
Octokit pagination</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/160">Updates
real world links</a></li>
</ul>
<h2>v2.10.2</h2>
<ul>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/91">Fix
getLocalRef() returns wrong ref</a></li>
</ul>
<h2>v2.10.1</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/85">Improve
robustness of change detection</a></li>
</ul>
<h2>v2.10.0</h2>
<ul>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/82">Add
ref input parameter</a></li>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/83">Fix
change detection in PR when pullRequest.changed_files is
incorrect</a></li>
</ul>
<h2>v2.9.3</h2>
<ul>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/78">Fix
change detection when base is a tag</a></li>
</ul>
<h2>v2.9.2</h2>
<ul>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/75">Fix
fetching git history</a></li>
</ul>
<h2>v2.9.1</h2>
<ul>
<li><a href="https://redirect.github.com/dorny/paths-filter/pull/74">Fix
fetching git history + fallback to unshallow repo</a></li>
</ul>
<h2>v2.9.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dorny/paths-filter/commit/fbd0ab8f3e69293af611ebaee6363fc25e6d187d"><code>fbd0ab8</code></a>
feat: add merge_group event support</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/efb1da7ce8d89bbc261191e5a2dc1453c3837339"><code>efb1da7</code></a>
feat: add dist/ freshness check to PR workflow</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/d8f7b061b24c30a325ff314b76c37adb05b041ce"><code>d8f7b06</code></a>
Merge pull request <a
href="https://redirect.github.com/dorny/paths-filter/issues/302">#302</a>
from dorny/issue-299</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/addbc147a95845176e1bc013a012fbf1d366389a"><code>addbc14</code></a>
Update README for v4</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/9d7afb8d214ad99e78fbd4247752c4caed2b6e4c"><code>9d7afb8</code></a>
Update CHANGELOG for v4.0.0</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/782470c5d953cae2693d643172b14e01bacb71f3"><code>782470c</code></a>
Merge branch 'releases/v3'</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/d1c1ffe0248fe513906c8e24db8ea791d46f8590"><code>d1c1ffe</code></a>
Update CHANGELOG for v3.0.3</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/ce10459c8b92cd8901166c0a222fbb033ef39365"><code>ce10459</code></a>
Merge pull request <a
href="https://redirect.github.com/dorny/paths-filter/issues/294">#294</a>
from saschabratton/master</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/5f40380c5482e806c81cec080f5192e7234d8fe9"><code>5f40380</code></a>
feat: update action runtime to node24</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/668c092af3649c4b664c54e4b704aa46782f6f7c"><code>668c092</code></a>
Merge pull request <a
href="https://redirect.github.com/dorny/paths-filter/issues/279">#279</a>
from wardpeet/patch-1</li>
<li>Additional commits viewable in <a
href="https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d">compare
view</a></li>
</ul>
</details>
<br />

Updates `coder/coder` from 2.31.3 to 2.31.5
<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.31.5</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>Prevent emitting build duration metric for devcontainer subagents
(<a
href="https://redirect.github.com/coder/coder/issues/22930">#22930</a>,
2cd4e03f1)</li>
<li>Prevent ui error when last org member is removed (<a
href="https://redirect.github.com/coder/coder/issues/23019">#23019</a>,
581e956b4)</li>
<li>Networking: Retry after transport dial timeouts (<a
href="https://redirect.github.com/coder/coder/issues/22977">#22977</a>,
1a774ab7c)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.31.4...v2.31.5"><code>v2.31.4...v2.31.5</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:2.31.5</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.31.4</h2>
<h2>Changelog</h2>
<blockquote>
<p>[!NOTE]
This is a mainline Coder release. We advise enterprise customers without
a staging environment to install our <a
href="https://github.com/coder/coder/releases/latest">latest stable
release</a> while we refine this version. Learn more about our <a
href="https://coder.com/docs/install/releases">Release Schedule</a>.</p>
</blockquote>
<h3>Features</h3>
<ul>
<li>Add Prometheus collector for DERP server expvar metrics (<a
href="https://redirect.github.com/coder/coder/issues/22583">#22583</a>,
a3792153d)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Filter sub-agents from build duration metric (<a
href="https://redirect.github.com/coder/coder/issues/22732">#22732</a>,
757634c72)</li>
<li>Bump aibridge to v1.0.9 to forward Anthropic-Beta header (<a
href="https://redirect.github.com/coder/coder/issues/22842">#22842</a>,
61b513e58)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.31.3...v2.31.4"><code>v2.31.3...v2.31.4</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:2.31.4</code></li>
</ul>
<h2>Install/upgrade</h2>
<p>Refer to our docs to <a
href="https://coder.com/docs/install">install</a> or <a
href="https://coder.com/docs/install/upgrade">upgrade</a> Coder, or use
a release asset below.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/coder/coder/commit/1a774ab7ce99063a2e01beb94de3fcbccaf84dbe"><code>1a774ab</code></a>
fix(tailnet): retry after transport dial timeouts (<a
href="https://redirect.github.com/coder/coder/issues/22977">#22977</a>)
(cherry-pick/v2.31...</li>
<li><a
href="https://github.com/coder/coder/commit/581e956b49bf34bc0145188aa7e15f3e7f8e71c4"><code>581e956</code></a>
fix: prevent ui error when last org member is removed (<a
href="https://redirect.github.com/coder/coder/issues/23019">#23019</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/2cd4e03f11dcf732f06af2899c0e896b2c2ee766"><code>2cd4e03</code></a>
fix: prevent emitting build duration metric for devcontainer subagents
(<a
href="https://redirect.github.com/coder/coder/issues/22930">#22930</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/61b513e586d7dd6ded81beaa6766689988427bad"><code>61b513e</code></a>
fix: bump aibridge to v1.0.9 to forward Anthropic-Beta header (<a
href="https://redirect.github.com/coder/coder/issues/22842">#22842</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/757634c720b03eea3c821add9784cb395ae76a9b"><code>757634c</code></a>
fix: filter sub-agents from build duration metric (<a
href="https://redirect.github.com/coder/coder/issues/22732">#22732</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22919">#22919</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/a3792153dea7efbd6dde31bd41159e4c79b985c7"><code>a379215</code></a>
feat: add Prometheus collector for DERP server expvar metrics (<a
href="https://redirect.github.com/coder/coder/issues/22583">#22583</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22917">#22917</a>)</li>
<li>See full diff in <a
href="https://github.com/coder/coder/compare/deaacff8437e3f4ee84bc51c4e5162f6dd7d190e...1a774ab7ce99063a2e01beb94de3fcbccaf84dbe">compare
view</a></li>
</ul>
</details>
<br />

Updates `oven-sh/setup-bun` from 2.1.3 to 2.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oven-sh/setup-bun/releases">oven-sh/setup-bun's
releases</a>.</em></p>
<blockquote>
<h2>v2.2.0</h2>
<p><code>oven-sh/setup-bun</code> is the github action for setting up
Bun.</p>
<h2>What's Changed</h2>
<ul>
<li>build: update action runtime to Node.js 24 by <a
href="https://github.com/adam0white"><code>@​adam0white</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/176">oven-sh/setup-bun#176</a></li>
<li>ci: use <code>actions/checkout@v6.0.2</code> in the test workflow by
<a href="https://github.com/tcely"><code>@​tcely</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/173">oven-sh/setup-bun#173</a></li>
<li>ci: update actions for the <code>autofix.ci</code> workflow by <a
href="https://github.com/tcely"><code>@​tcely</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/174">oven-sh/setup-bun#174</a></li>
<li>ci: update actions for the <code>Release new action version</code>
workflow by <a href="https://github.com/tcely"><code>@​tcely</code></a>
in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/175">oven-sh/setup-bun#175</a></li>
<li>release: v2.2.0 by <a
href="https://github.com/xhyrom"><code>@​xhyrom</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/177">oven-sh/setup-bun#177</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/adam0white"><code>@​adam0white</code></a> made
their first contribution in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/176">oven-sh/setup-bun#176</a></li>
<li><a href="https://github.com/tcely"><code>@​tcely</code></a> made
their first contribution in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/173">oven-sh/setup-bun#173</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oven-sh/setup-bun/compare/v2...v2.2.0">https://github.com/oven-sh/setup-bun/compare/v2...v2.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/0c5077e51419868618aeaa5fe8019c62421857d6"><code>0c5077e</code></a>
release: v2.2.0 (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/177">#177</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/1255e43b02f74b77bb39330ef756405951c3303a"><code>1255e43</code></a>
ci: update actions for the <code>Release new action version</code>
workflow (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/175">#175</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/61861d1f6a3acf561f12343ea89e2c71ff4af529"><code>61861d1</code></a>
ci: update actions for the <code>autofix.ci</code> workflow (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/174">#174</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/6f5bd063f58cadd19ae42cca8bb41b191e9949bd"><code>6f5bd06</code></a>
ci: use <code>actions/checkout@v6.0.2</code> in the test workflow (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/173">#173</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/e3914758a49697077f7bcd190d36582a61667aad"><code>e391475</code></a>
build: update action runtime to Node.js 24 (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/176">#176</a>)</li>
<li>See full diff in <a
href="https://github.com/oven-sh/setup-bun/compare/ecf28ddc73e819eb6fa29df6b34ef8921c743461...0c5077e51419868618aeaa5fe8019c62421857d6">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-03-16 12:54:51 +05:00
35C4n0r 4fdcf0d712 fix(coder/modules/claude-code): update claude session workdir normalization (#803)
## Description

- This lead to a bug where if the folder name is in the form `a.b.c`:
- we check for:
`-home-coder-ai.coder.com/cd32e253-ca16-4fd3-9825-d837e74ae3c2.jsonl`
- But the actual file path for claude-session is:
`-home-coder-ai-coder-com/cd32e253-ca16-4fd3-9825-d837e74ae3c2.jsonl`
- The above bug might also occur in the case of `a_b_c`
- update workdir normalization to handle dot in path

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2026-03-13 12:43:31 -05:00
blink-so[bot] acdbd5777f fix: align terraform blocks to pass prettier formatting 2026-03-13 14:24:57 +00:00
blink-so[bot] 8bd24f8b44 simplify: just add pre/post install script variables inline in run.sh
Remove all the coder exp sync machinery, separate script resources,
locals, and outputs. Simply pass the scripts as template variables
into run.sh and eval them before/after the nvm install.
2026-03-13 14:23:14 +00:00
Michael Suchacz 1460293de4 feat(coder/mux): add restart retries for mux exits (#800)
## Summary
- add optional mux auto-restarts with delay, lock cleanup, and
restart-attempt caps
- restart mux after any exit when enabled, including intentional exits
and signals
- require `max_restart_attempts` to be a non-negative whole number and
update docs/tests for the new restart semantics

## Validation
- `bash -n registry/coder/modules/mux/run.sh`
- `cd registry/coder/modules/mux && terraform validate`
- `cd registry/coder/modules/mux && terraform test -verbose`
- `cd registry/coder/modules/mux && bun test main.test.ts`

Generated with OpenAI using Mux
2026-03-13 09:16:38 -05:00
blink-so[bot] 79cef2ecfc feat(nodejs): add pre and post install scripts with coder exp sync support
Add pre_install_script and post_install_script variables to the nodejs
module following the pattern used by other registry modules (agent-helper,
claude-code, aider, etc.). Scripts use coder exp sync for reliable
execution ordering, enabling dependency coordination between modules.

Changes:
- Add pre_install_script and post_install_script optional variables
- Wrap install script with coder exp sync want/start/complete
- Add conditional pre/post install coder_script resources
- Export sync script names as outputs for cross-module coordination
- Add nodejs.tftest.hcl with 5 test cases
- Update README with pre/post install documentation and examples
- Bump version references to 1.0.14
2026-03-13 14:07:52 +00:00
blinkagent[bot] 9606297620 feat: pass branch to coder dotfiles (#789)
Supersedes #551 (fork branch couldn't be rebased due to GitHub App
permission limitations).

Original author: @willshu

## Description

Adds support for specifying a git branch when cloning dotfiles
repositories.

### Changes
- Introduces `dotfiles_branch` and `default_dotfiles_branch` Terraform
variables
- Adds a `coder_parameter` for `dotfiles_branch` when not explicitly set
(with `order` matching `dotfiles_uri`)
- Conditionally passes the `--branch` flag to `coder dotfiles` only when
branch is non-empty
- Adds validation to prevent empty string for `dotfiles_branch` (use
`null` to prompt the user)
- Default branch is empty string — defers to the repo's default branch
rather than assuming `main`, matching the behavior of `coder dotfiles
--branch` which states: *"If empty, will default to cloning the default
branch or using the existing branch in the cloned repo on disk."*
- Adds test coverage for custom branch setting and parameter creation

### Review feedback addressed (from Copilot on #551)
- Added `order` field to `dotfiles_branch` parameter for UI consistency
with `dotfiles_uri`
- Conditional echo message — only shows branch info when set
- `--branch` flag only passed when `DOTFILES_BRANCH` is non-empty (both
current-user and sudo paths)
- Added validation block on `var.dotfiles_branch` to reject empty
strings

## Type of Change

- [x] Feature/enhancement

## Module Information

**Path:** `registry/coder/modules/dotfiles`

## Testing & Validation

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

Co-authored-by: William Shu <william.shu@kkr.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-11 16:11:19 -05:00
Shane White a0430e6f83 feat(coder-labs/modules/codex): add boundary support via agentapi module (#795)
## Description
Adds boundary support to the Codex module by passing boundary
variables through to the agentapi module and using
AGENTAPI_BOUNDARY_PREFIX in the start script.

Depends on #780

## Type of Change
- [x] Feature/enhancement

## Module Information
**Path:** `registry/coder-labs/modules/codex`
**Breaking change:** No

---------

Co-authored-by: Shane White <shane.white@cloudsecure.ltd>
Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-03-11 23:07:37 +05:30
Shane White 2ee14fdf6e feat: provide boundary support for agent modules (#780)
## Description
Enable any agent module to run its AI agent inside Coder's Agent
Boundaries.
The agentapi module handles boundary installation, config setup, and
wrapper
script creation, then exports AGENTAPI_BOUNDARY_PREFIX for consuming
modules
to use in their start scripts.

Supports three boundary installation modes:
- coder boundary subcommand (default, Coder v2.30+)
- Standalone binary via install script (use_boundary_directly)
- Compiled from source (compile_boundary_from_source)

Users must provide a boundary config.yaml with their allowlist and
settings when enabling boundary.

Closes #457

## Type of Change
- [x] Feature/enhancement

## Module Information
**Path:** `registry/coder/modules/agentapi`
**Breaking change:** No

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

---------

Co-authored-by: Shane White <shane.white@cloudsecure.ltd>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
2026-03-11 20:01:50 +05:30
Michael Suchacz 183bd57061 fix: log external mux server exits in launcher (#796)
## Summary
Keep the Mux module's launcher around after startup so it can append
useful diagnostics when `mux server` is killed outside the Node runtime.

## Background
The module previously forked `mux server` and returned immediately,
which meant external kills (for example `SIGKILL` or an OOM kill) could
leave users with only a stopped app and no launcher-side clue about what
happened.

## Implementation
- keep the existing module inputs and startup shape intact
- launch `mux server` under a detached Bash watcher that waits for the
child process to exit
- append signal/exit-code diagnostics to `log_path` when the server dies
unexpectedly
- include a best-effort kernel OOM/SIGKILL hint in the log when the host
exposes it
- add Terraform and Bun tests that cover the new launcher diagnostics
- bump the module examples from `1.3.1` to `1.4.0`

## Validation
- `bun x prettier --check registry/coder/modules/mux/README.md
registry/coder/modules/mux/main.test.ts
registry/coder/modules/mux/mux.tftest.hcl
registry/coder/modules/mux/run.sh`
- `terraform fmt -check -recursive registry/coder/modules/mux`
- `cd registry/coder/modules/mux && terraform validate`
- `cd registry/coder/modules/mux && terraform test -verbose`
- `cd registry/coder/modules/mux && bun test main.test.ts`
- `bun run shellcheck -- registry/coder/modules/mux/run.sh`

---

Generated with mux (exec mode) using openai:gpt-5.4.
2026-03-10 14:32:58 +01:00
DevCats 5a241ebce2 feat: ttyd module (#790)
## Description

Add ttyd module that exposes any command as a web-based terminal via
[ttyd](https://github.com/tsl0922/ttyd).

- Run commands like `bash`, `htop`, or `tmux` accessible in the browser
- Supports readonly mode for log viewers
- Configurable sharing (owner/authenticated/public)
- Auto-installs ttyd binary (x86_64, aarch64, ARM)
- Works with subdomain or path-based routing


![TTYD-Module-Demo](https://github.com/user-attachments/assets/1c884e89-b1b1-4f1b-ab5b-56df3dd6d9af)

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/ttyd`  
**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

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 16:19:10 +00:00
blinkagent[bot] 4b3045e637 docs: clarify that READMEs should not include input/output variable tables (#787)
The registry auto-generates input/output documentation from
`variables.tf` and `outputs.tf`, so including these tables in
module/template READMEs is redundant and prone to drift.

This adds two bullets to the **Code Style** section of `AGENTS.md`:

- Do not include input/output variable tables in READMEs
- Usage examples (e.g., `module "..." { }` blocks) are still encouraged

Created on behalf of @DevelopmentCats

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-09 16:16:28 +00:00
dependabot[bot] d7566cc618 chore(deps): bump the github-actions group across 1 directory with 5 updates (#791)
Bumps the github-actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [coder/coder](https://github.com/coder/coder) | `2.29.2` | `2.31.3` |
| [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun) | `2.1.2` |
`2.1.3` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.42.1` |
`1.44.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.2.0` |
`6.3.0` |
|
[zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action)
| `0.4.1` | `0.5.2` |


Updates `coder/coder` from 2.29.2 to 2.31.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.31.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>Chores</h3>
<h3>Bug Fixes</h3>
<ul>
<li>fix: early oidc refresh with fake idp tests (cherry 2.31) (<a
href="https://redirect.github.com/coder/coder/issues/22716">#22716</a>,
deaacff84) (<a
href="https://github.com/Emyrk"><code>@​Emyrk</code></a>)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.31.2...v2.31.3"><code>v2.31.2...v2.31.3</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:v2.31.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.31.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>Chores</h3>
<ul>
<li>Prematurely refresh oidc token near expiry during workspace (cherry
2.31) (<a
href="https://redirect.github.com/coder/coder/issues/22606">#22606</a>,
2828d28e0) (<a
href="https://github.com/Emyrk"><code>@​Emyrk</code></a>)</li>
</ul>
<p>Compare: <a
href="https://github.com/coder/coder/compare/v2.31.1...v2.31.2"><code>v2.31.1...v2.31.2</code></a></p>
<h2>Container image</h2>
<ul>
<li><code>docker pull ghcr.io/coder/coder:v2.31.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.31.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>
<p>Normally, our monthly releases are 2.X.0. This mainline release is
2.X.1 due to an issue in the release process, but it should be
considered a standard mainline release for customers.</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/coder/coder/commit/deaacff8437e3f4ee84bc51c4e5162f6dd7d190e"><code>deaacff</code></a>
fix: early oidc refresh with fake idp tests (<a
href="https://redirect.github.com/coder/coder/issues/22712">#22712</a>)
(cherry 2.31) (<a
href="https://redirect.github.com/coder/coder/issues/22716">#22716</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/2828d28e0c2b0a734876a1513afedd7cc7137508"><code>2828d28</code></a>
chore: prematurely refresh oidc token near expiry during workspace
(cherry 2....</li>
<li><a
href="https://github.com/coder/coder/commit/4b95b8b4f952f6c3414eec3adb8184dd1a2a3e71"><code>4b95b8b</code></a>
fix(coderd): add organization_name label to insights Prometheus metrics
(cher...</li>
<li><a
href="https://github.com/coder/coder/commit/3a061ccb21f8393ba657edce53d57baa8c5800b2"><code>3a061cc</code></a>
refactor(site): use dedicated task pause/resume API endpoints (<a
href="https://redirect.github.com/coder/coder/issues/22303">#22303</a>)
(cherr...</li>
<li><a
href="https://github.com/coder/coder/commit/22c2da53e92b0ffacbf12ac22c70065c6f0ffb3c"><code>22c2da5</code></a>
fix: register task pause/resume routes under /api/v2 (<a
href="https://redirect.github.com/coder/coder/issues/22544">#22544</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22550">#22550</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/ccb529e98ab64e5d7435fa20fbc6cffb60e05185"><code>ccb529e</code></a>
fix: disable sharing ui when sharing is unavailable (<a
href="https://redirect.github.com/coder/coder/issues/22390">#22390</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22561">#22561</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/107fd97a61930dcb725d5de1211781d03ac8884f"><code>107fd97</code></a>
fix: avoid derp-related panic during wsproxy registration (backport
release/2...</li>
<li><a
href="https://github.com/coder/coder/commit/955637a79d2c69dfc9f9a17332b97cf703d62ec4"><code>955637a</code></a>
fix(codersdk): use header auth for non-browser websocket dials (<a
href="https://redirect.github.com/coder/coder/issues/22461">#22461</a>)
(cher...</li>
<li><a
href="https://github.com/coder/coder/commit/85f1d70c4f71f796729223d573a679b1f74a6efb"><code>85f1d70</code></a>
ci: add temporary deploy override (<a
href="https://redirect.github.com/coder/coder/issues/22378">#22378</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22475">#22475</a>)</li>
<li><a
href="https://github.com/coder/coder/commit/e9e438b06ea736dc00ff100cc8dda91f38b9611a"><code>e9e438b</code></a>
fix(stringutil): operate on runes instead of bytes in Truncate (<a
href="https://redirect.github.com/coder/coder/issues/22388">#22388</a>)
(<a
href="https://redirect.github.com/coder/coder/issues/22469">#22469</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/coder/coder/compare/b5360a9180613328a62d64efcfaac5a31980c746...deaacff8437e3f4ee84bc51c4e5162f6dd7d190e">compare
view</a></li>
</ul>
</details>
<br />

Updates `oven-sh/setup-bun` from 2.1.2 to 2.1.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oven-sh/setup-bun/releases">oven-sh/setup-bun's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.3</h2>
<p><code>oven-sh/setup-bun</code> is the github action for setting up
Bun.</p>
<h2>What's Changed</h2>
<ul>
<li>perf: avoid unnecessary api calls by <a
href="https://github.com/xhyrom"><code>@​xhyrom</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/161">oven-sh/setup-bun#161</a></li>
<li>feat: add bun- prefix to cache keys by <a
href="https://github.com/maschwenk"><code>@​maschwenk</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/160">oven-sh/setup-bun#160</a></li>
<li>fix: use native Windows ARM64 binary for Bun &gt;= 1.3.10 by <a
href="https://github.com/oddrationale"><code>@​oddrationale</code></a>
in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/165">oven-sh/setup-bun#165</a></li>
<li>feat: add AVX2 support detection for x64 Linux systems by <a
href="https://github.com/GoForceX"><code>@​GoForceX</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/167">oven-sh/setup-bun#167</a></li>
<li>fix: validate cached binary version matches requested version (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/146">#146</a>)
by <a href="https://github.com/wyMinLwin"><code>@​wyMinLwin</code></a>
in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/169">oven-sh/setup-bun#169</a></li>
<li>release: v2.1.3 by <a
href="https://github.com/xhyrom"><code>@​xhyrom</code></a> in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/170">oven-sh/setup-bun#170</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/oddrationale"><code>@​oddrationale</code></a>
made their first contribution in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/165">oven-sh/setup-bun#165</a></li>
<li><a href="https://github.com/GoForceX"><code>@​GoForceX</code></a>
made their first contribution in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/167">oven-sh/setup-bun#167</a></li>
<li><a href="https://github.com/wyMinLwin"><code>@​wyMinLwin</code></a>
made their first contribution in <a
href="https://redirect.github.com/oven-sh/setup-bun/pull/169">oven-sh/setup-bun#169</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oven-sh/setup-bun/compare/v2...v2.1.3">https://github.com/oven-sh/setup-bun/compare/v2...v2.1.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/ecf28ddc73e819eb6fa29df6b34ef8921c743461"><code>ecf28dd</code></a>
release: v2.1.3 (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/170">#170</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/95edc153a3f71202eb7d8f0ee7b43c6b8b16763f"><code>95edc15</code></a>
fix: validate cached binary version matches requested version (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/146">#146</a>)
(<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/169">#169</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/4c32875876eebbbb9bc34b8ee07ba2d7bb4b3462"><code>4c32875</code></a>
feat: add AVX2 support detection for x64 Linux systems (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/167">#167</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/0ff83bfc51e05dd2251088164ec6a5e8533b476b"><code>0ff83bf</code></a>
fix: use native Windows ARM64 binary for Bun &gt;= 1.3.10 (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/165">#165</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/ab8cb4e8f89912a29b87e4abc4554f2301648a5c"><code>ab8cb4e</code></a>
feat: add bun- prefix to cache keys (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/160">#160</a>)</li>
<li><a
href="https://github.com/oven-sh/setup-bun/commit/196aaa2bd27ecf519a9475d2da77b448974ab92c"><code>196aaa2</code></a>
perf: avoid unnecessary api calls (<a
href="https://redirect.github.com/oven-sh/setup-bun/issues/161">#161</a>)</li>
<li>See full diff in <a
href="https://github.com/oven-sh/setup-bun/compare/3d267786b128fe76c2f16a390aa2448b815359f3...ecf28ddc73e819eb6fa29df6b34ef8921c743461">compare
view</a></li>
</ul>
</details>
<br />

Updates `crate-ci/typos` from 1.42.1 to 1.44.0
<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.44.0</h2>
<h2>[1.44.0] - 2026-02-27</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1488">February
2026</a> changes</li>
</ul>
<h2>v1.43.5</h2>
<h2>[1.43.5] - 2026-02-16</h2>
<h3>Fixes</h3>
<ul>
<li><em>(pypi)</em> Hopefully fix the sdist build</li>
</ul>
<h2>v1.43.4</h2>
<h2>[1.43.4] - 2026-02-09</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>pincher</code></li>
</ul>
<h2>v1.43.3</h2>
<h2>[1.43.3] - 2026-02-06</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Adjust how typos are reported to github</li>
</ul>
<h2>v1.43.2</h2>
<h2>[1.43.2] - 2026-02-05</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>certifi</code> in Python</li>
</ul>
<h2>v1.43.1</h2>
<h2>[1.43.1] - 2026-02-03</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>consts</code></li>
</ul>
<h2>v1.43.0</h2>
<h2>[1.43.0] - 2026-02-02</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1453">January
2026</a> changes</li>
</ul>
<h2>v1.42.3</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</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.44.0] - 2026-02-27</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1488">February
2026</a> changes</li>
</ul>
<h2>[1.43.5] - 2026-02-16</h2>
<h3>Fixes</h3>
<ul>
<li><em>(pypi)</em> Hopefully fix the sdist build</li>
</ul>
<h2>[1.43.4] - 2026-02-09</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>pincher</code></li>
</ul>
<h2>[1.43.3] - 2026-02-06</h2>
<h3>Fixes</h3>
<ul>
<li><em>(action)</em> Adjust how typos are reported to github</li>
</ul>
<h2>[1.43.2] - 2026-02-05</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>certifi</code> in Python</li>
</ul>
<h2>[1.43.1] - 2026-02-03</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>consts</code></li>
</ul>
<h2>[1.43.0] - 2026-02-02</h2>
<h3>Compatibility</h3>
<ul>
<li>Bumped MSRV to 1.91</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/631208b7aac2daa8b707f55e7331f9112b0e062d"><code>631208b</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/3d3c6e376823e66c4f3e2583fc47b8be83b66d71"><code>3d3c6e3</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/ba1f545443d223c6bc2c821dad76c210fa78b46f"><code>ba1f545</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/102f66c093f0eb1a69937d3d1c589d5f16c5569b"><code>102f66c</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1510">#1510</a>
from epage/feb</li>
<li><a
href="https://github.com/crate-ci/typos/commit/d303c9398affd88fc562292a2ec9433a37817b28"><code>d303c93</code></a>
feat(dict): February updates</li>
<li><a
href="https://github.com/crate-ci/typos/commit/30eea72e385d435c00a24eeba0d96f87048f42ec"><code>30eea72</code></a>
chore(ci): Update pre-build binary workflow</li>
<li><a
href="https://github.com/crate-ci/typos/commit/57b11c6b7e54c402ccd9cda953f1072ec4f78e33"><code>57b11c6</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/105ced22a5a7fedc36cbef6e5dec31b708e9ec5b"><code>105ced2</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/4f89be7e4a7933f8d9693a9da7a9e9258a8671ba"><code>4f89be7</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1504">#1504</a>
from schnellerhase/bump-maturin</li>
<li><a
href="https://github.com/crate-ci/typos/commit/d8547ad9c141d0e2c568b2344f0804a446ff25ab"><code>d8547ad</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1503">#1503</a>
from 1195343015/patch-1</li>
<li>Additional commits viewable in <a
href="https://github.com/crate-ci/typos/compare/65120634e79d8374d1aa2f27e54baa0c364fff5a...631208b7aac2daa8b707f55e7331f9112b0e062d">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-go` from 6.2.0 to 6.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update default Go module caching to use go.mod by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/705">actions/setup-go#705</a></li>
<li>Fix golang download url to go.dev by <a
href="https://github.com/178inaba"><code>@​178inaba</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/469">actions/setup-go#469</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v6.3.0">https://github.com/actions/setup-go/compare/v6...v6.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-go/commit/4b73464bb391d4059bd26b0524d20df3927bd417"><code>4b73464</code></a>
Fix golang download url to go.dev (<a
href="https://redirect.github.com/actions/setup-go/issues/469">#469</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/a5f9b05d2d216f63e13859e0d847461041025775"><code>a5f9b05</code></a>
Update default Go module caching to use go.mod (<a
href="https://redirect.github.com/actions/setup-go/issues/705">#705</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417">compare
view</a></li>
</ul>
</details>
<br />

Updates `zizmorcore/zizmor-action` from 0.4.1 to 0.5.2
<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.2</h2>
<h2>What's Changed</h2>
<ul>
<li>zizmor 1.23.1 is now the default used by this action.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.5.1...v0.5.2">https://github.com/zizmorcore/zizmor-action/compare/v0.5.1...v0.5.2</a></p>
<h2>v0.5.1</h2>
<h2>What's Changed</h2>
<ul>
<li>zizmor 1.23.0 is now the default used by this action.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.5.0...v0.5.1">https://github.com/zizmorcore/zizmor-action/compare/v0.5.0...v0.5.1</a></p>
<h2>v0.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Expose <code>output-file</code> as an output when
<code>advanced-security: true</code> by <a
href="https://github.com/unlobito"><code>@​unlobito</code></a> in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/87">zizmorcore/zizmor-action#87</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/unlobito"><code>@​unlobito</code></a>
made their first contribution in <a
href="https://redirect.github.com/zizmorcore/zizmor-action/pull/87">zizmorcore/zizmor-action#87</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/zizmorcore/zizmor-action/compare/v0.4.1...v0.5.0">https://github.com/zizmorcore/zizmor-action/compare/v0.4.1...v0.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/71321a20a9ded102f6e9ce5718a2fcec2c4f70d8"><code>71321a2</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/96">#96</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5ed31db0964a9d37608edd5b0675de2b52070662"><code>5ed31db</code></a>
Bump pins (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/95">#95</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f"><code>195d10a</code></a>
Sync zizmor versions (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/94">#94</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/c65bc8876171b6d82748ec98b77c0193b1226b94"><code>c65bc88</code></a>
chore(deps): bump github/codeql-action in the github-actions group (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/93">#93</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/c2c887f84674f9c15123e2905d2d307675d8bc01"><code>c2c887f</code></a>
chore(deps): bump zizmorcore/zizmor-action in the github-actions group
(<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/91">#91</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/5507ab0c02a9ac3996895e1598d6b3385ea7d525"><code>5507ab0</code></a>
Bump pins in README (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/90">#90</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d"><code>0dce257</code></a>
chore(deps): bump peter-evans/create-pull-request (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/88">#88</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/fb9497493b591ad90176d3ecac5ca4aeff8c9faf"><code>fb94974</code></a>
Expose <code>output-file</code> as an output when
<code>advanced-security: true</code> (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/87">#87</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/867562a69bb7adcc63dd1e8c003600a58b5f70e2"><code>867562a</code></a>
chore(deps): bump the github-actions group with 2 updates (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/85">#85</a>)</li>
<li><a
href="https://github.com/zizmorcore/zizmor-action/commit/7462f075f718787753331c6d98ca9ef8eb41e735"><code>7462f07</code></a>
Bump pins in README (<a
href="https://redirect.github.com/zizmorcore/zizmor-action/issues/84">#84</a>)</li>
<li>See full diff in <a
href="https://github.com/zizmorcore/zizmor-action/compare/135698455da5c3b3e55f73f4419e481ab68cdd95...71321a20a9ded102f6e9ce5718a2fcec2c4f70d8">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-03-09 16:20:08 +05:00
blink-so[bot] 40c2916fa9 feat: add JFrog Xray vulnerability scanning module (#410)
This PR adds a new Terraform module that fetches JFrog Xray
vulnerability scanning results for container images stored in
Artifactory.

## Features
- Fetches vulnerability scan results from JFrog Xray
- Outputs vulnerability counts (Critical, High, Medium, Low, Total)
- Supports flexible image path formats
- Works with any workspace type using container images
- Provides secure token handling

## Design Decisions

During testing, we found two issues with the original approach of
defining the `xray` provider and `coder_metadata` inside the module:

1. **`coder_metadata` defined inside modules does not display in the
Coder dashboard** — this is a known limitation
2. **Inline provider blocks prevent using `count`/`for_each` on the
module** — which is needed when attaching metadata to resources like
`docker_container` that use `start_count`

The module now **outputs** vulnerability counts instead, and the caller
creates the `coder_metadata` and configures the `xray` provider in their
root template. This matches the pattern used by other registry modules.

## Usage

```hcl
provider "xray" {
  url                     = "${var.jfrog_url}/xray"
  access_token            = var.artifactory_access_token
  skip_xray_version_check = true
}

module "jfrog_xray" {
  source  = "registry.coder.com/coder/jfrog-xray/coder"
  version = "1.0.0"

  xray_url   = "${var.jfrog_url}/xray"
  xray_token = var.artifactory_access_token
  image      = "docker-local/codercom/enterprise-base:latest"
}

resource "coder_metadata" "xray_vulnerabilities" {
  count       = data.coder_workspace.me.start_count
  resource_id = docker_container.workspace[0].id
  icon        = "/icon/shield.svg"

  item {
    key   = "Total Vulnerabilities"
    value = module.jfrog_xray.total
  }
  item {
    key   = "Critical"
    value = module.jfrog_xray.critical
  }
  item {
    key   = "High"
    value = module.jfrog_xray.high
  }
  item {
    key   = "Medium"
    value = module.jfrog_xray.medium
  }
  item {
    key   = "Low"
    value = module.jfrog_xray.low
  }
}
```

## Related Issues
- Resolves coder/coder#12838
- Addresses coder/registry#65

Tested with a JFrog Cloud trial instance using Docker remote repository
and Xray scanning.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
Co-authored-by: DevelopmentCats <christofer@coder.com>
2026-03-06 07:45:33 -06:00
35C4n0r f1748c80f7 feat(coder-labs/modules/codex): add support for agentapi state_persistence (#785)
## Description

- add support for agentapi state_persistence

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

Closes: #783
2026-03-05 19:20:21 +05:30
Susana Ferreira f6a09d4c34 ci: remove branch filter to support stacked PRs (#786) 2026-03-05 15:39:14 +05:00
Susana Ferreira 7e75d5d762 feat: add AI Bridge Proxy support to copilot module (#725)
## Description

Add AI Bridge Proxy support to the copilot module. When enabled, the module configures proxy environment variables (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`) scoped to the copilot process tree (agentapi and copilot), routing Copilot traffic through AI Bridge Proxy without affecting other workspace traffic.

GitHub authentication is still required, the proxy authenticates with AI Bridge using the Coder session token but does not replace GitHub authentication.

Note: Uses [coder exp sync](https://coder.com/docs/admin/templates/startup-coordination) for startup coordination, ensuring the copilot module waits for the `aibridge-proxy` setup to complete before starting.

## Type of Change

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

## Module Information

**Path:** `registry/coder-labs/modules/copilot`  
**New version:** `v0.4.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

Depends on: #721
Related to: https://github.com/coder/internal/issues/1187
2026-03-05 09:34:41 +00:00
Susana Ferreira b6c2998eb3 feat: add aibridge-proxy module for AI Bridge Proxy workspace setup (#721)
## Description

Add `aibridge-proxy` module that configures workspaces to use AI Bridge Proxy. Downloads the proxy's CA certificate and exposes `proxy_auth_url` and `cert_path` outputs for tool-specific modules to configure the proxy scoped to their process. The module does not set proxy environment variables globally in the workspace.

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

Closes: https://github.com/coder/internal/issues/1187
2026-03-05 09:27:01 +00:00
Jason Barnett ac49e6eef5 docs(claude-code): document pre_install_script for module dependency ordering (#613)
## Summary

Clarifies that the existing `pre_install_script` variable can be used to
handle dependencies between modules during workspace startup.

## Problem

When using multiple startup modules (e.g., git-clone and claude-code),
there's a race condition where scripts execute in parallel. Module
dependencies need to be managed, such as ensuring git-clone completes
before Claude Code tries to access a workdir.

## Solution

The existing `pre_install_script` variable already provides this
capability. Updated documentation to clarify this use case.

## Example

```hcl
module "claude-code" {
  source = "registry.coder.com/coder/claude-code/coder"
  
  workdir = "/path/to/repo"
  
  # Wait for git-clone to complete before starting
  pre_install_script = <<-EOT
    #!/bin/bash
    set -e
    while [ ! -f /tmp/.git-clone-complete ]; do
      sleep 1
    done
  EOT
}
```

Resolves issue #609.

Co-authored-by: Jason Barnett <Jason.Barnett@altana.ai>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-03 15:28:48 -06:00
justmanuel 63e28c0e95 Enable Devcontainer-cli module to block user login until script finishes running (#759)
## Description
Allow for devcontainer-cli module to prevent users from logging in until
its finished running.

## Type of Change

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

## Module Information

**Path:** `registry/coder/modules/devcontainers-cli`  
**New version:** `1.1.0`  
**Breaking change:** [ ] Yes [x ] No

## Testing & Validation

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

## Related Issues
None

---------

Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: DevCats <christofer@coder.com>
2026-03-03 15:01:05 -06:00
DevCats eed8e6c29a feat(vscode-web): enhance settings management and testing for VS Code Web (#758)
This pull request enhances the VS Code Web module by improving how
machine settings are handled and merged, updating documentation to
clarify the settings behavior, and adding robust automated tests for the
new functionality. The most significant changes are grouped below.

**Machine Settings Handling and Merging:**

* Introduced a new `merge_settings` function in `run.sh` that merges
provided settings with any existing machine settings using `jq` or
`python3` if available, falling back gracefully if neither is present.
Settings are now passed as base64-encoded JSON to avoid quoting issues.
[[1]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323R7-R54)
[[2]](diffhunk://#diff-c6d09ac3d801a2417c0e3cf8c2cd0f093ba2cf245bad8c213f70115c75276323L31-R76)
[[3]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92L180-R184)
[[4]](diffhunk://#diff-0c7f0791e2c2556eb4ed7666ac44534ea3ff5c7f652e01716e5d7b5c31180d92R170-R173)
* Updated the `settings` variable in `main.tf` to clarify that it
applies to VS Code Web's Machine settings and will be merged with any
existing settings on startup.

**Documentation Improvements:**

* Updated the README to clarify that settings are merged with existing
machine settings, not simply overwritten, and added a note about the
requirements (`jq` or `python3`) and limitations regarding persistence
of user settings.
[[1]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dL54-R56)
[[2]](diffhunk://#diff-24e2e305e46a08f8a30243bdc916241586e4561d97861b4397b14e871f9f085dR72-R73)

**Automated Testing:**

* Expanded `main.test.ts` to include integration tests that verify
settings file creation and merging behavior inside a container, as well
as improved error handling for invalid configuration combinations.

These changes collectively make machine settings management more robust,
user-friendly, and well-documented.
2026-03-03 11:30:32 -06:00
Mathias Fredriksson 7b245549ec feat(coder/modules/claude-code): add enable_state_persistence variable (#749)
feat(coder/modules/claude-code): add enable_state_persistence variable

Expose the agentapi module's state persistence toggle so users can
control conversation state persistence across workspace restarts.
Enabled by default, set `enable_state_persistence = false` to disable.

Also bumps agentapi dependency from 2.0.0 to 2.2.0 and claude-code
to 4.8.0.

Refs coder/internal#1258
2026-03-03 18:03:57 +02:00
63 changed files with 4417 additions and 199 deletions
+8 -8
View File
@@ -1,7 +1,7 @@
name: CI
on:
pull_request:
branches: [main]
# Cancel in-progress runs for pull requests when developers push new changes
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -14,7 +14,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
list-files: shell
@@ -37,9 +37,9 @@ jobs:
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
- name: Set up Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
# We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases
@@ -82,18 +82,18 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
with:
config: .github/typos.toml
validate-readme-files:
@@ -106,7 +106,7 @@ jobs:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: "1.24.0"
- name: Validate contributors
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: stable
- name: golangci-lint
+2 -2
View File
@@ -26,12 +26,12 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
- 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@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
advanced-security: false
annotations: true
@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
inputs: |
.github/workflows
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
<g fill="#40BE46">
<!-- Eye shape -->
<path d="M100 40C55 40 20 80 10 100c10 20 45 60 90 60s80-40 90-60c-10-20-45-60-90-60zm0 100c-35 0-63-28-75-40 12-12 40-40 75-40s63 28 75 40c-12 12-40 40-75 40z"/>
<!-- Inner circle (magnifying glass lens) -->
<path d="M100 72a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 44a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/>
<!-- Horizontal line below -->
<rect x="25" y="170" width="150" height="12" rx="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 542 B

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

After

Width:  |  Height:  |  Size: 745 B

+2
View File
@@ -28,6 +28,8 @@ bun test main.test.ts # Run single TS test (from
- Use semantic versioning; bump version via script when modifying modules
- Docker tests require Linux or Colima/OrbStack (not Docker Desktop)
- Use `tf` (not `hcl`) for code blocks in README; use relative icon paths (e.g., `../../../../.icons/`)
- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift.
- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
## PR Review Checklist
+41 -13
View File
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
@@ -32,7 +32,7 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.3.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -60,23 +60,18 @@ module "codex" {
When `enable_aibridge = true`, the module:
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
- Configures Codex to use the aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
model_provider = "aibridge"
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex then runs with `--profile aibridge`
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
@@ -94,7 +89,7 @@ data "coder_task" "me" {}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_task.me.prompt
@@ -105,6 +100,26 @@ module "codex" {
}
```
### Usage with Agent Boundaries
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.3.1"
agent_id = coder_agent.main.id
openai_api_key = var.openai_api_key
workdir = "/home/coder/project"
enable_boundary = true
}
```
> [!NOTE]
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
### Advanced Configuration
This example shows additional configuration options for custom models, MCP servers, and base configuration.
@@ -112,7 +127,7 @@ This example shows additional configuration options for custom models, MCP serve
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.2"
version = "4.3.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
@@ -148,6 +163,19 @@ module "codex" {
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "codex" {
# ... other config
enable_state_persistence = false
}
```
## Configuration
### Default Configuration
+43 -3
View File
@@ -468,9 +468,49 @@ describe("codex", async () => {
id,
"/home/coder/.codex/config.toml",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
expect(configToml).toContain('model_provider = "aibridge"');
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.openai.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
if [ "$2" = "--help" ]; then
echo "boundary help"
exit 0
fi
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify boundary wrapper was used in start script
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(configToml).toContain('profile = "aibridge"');
expect(startLog).toContain("boundary");
});
});
+67 -28
View File
@@ -84,10 +84,10 @@ variable "enable_aibridge" {
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
description = "The reasoning effort for the model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = ""
validation {
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
@@ -131,13 +131,13 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.11.8"
default = "v0.12.1"
}
variable "codex_model" {
type = string
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
default = "gpt-5.3-codex"
default = "gpt-5.4"
}
variable "pre_install_script" {
@@ -164,12 +164,48 @@ variable "continue" {
default = true
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
variable "codex_system_prompt" {
type = string
description = "System instructions written to AGENTS.md in the ~/.codex directory"
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
default = false
}
resource "coder_env" "openai_api_key" {
agent_id = var.agent_id
name = "OPENAI_API_KEY"
@@ -189,7 +225,7 @@ locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
latest_codex_model = "gpt-5.3-codex"
latest_codex_model = "gpt-5.4"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
@@ -197,34 +233,36 @@ locals {
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
version = "2.3.0"
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
enable_boundary = var.enable_boundary
boundary_config_path = var.boundary_config_path
boundary_version = var.boundary_version
compile_boundary_from_source = var.compile_boundary_from_source
use_boundary_directly = var.use_boundary_directly
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -260,6 +298,7 @@ module "agentapi" {
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
@@ -0,0 +1,187 @@
run "test_codex_basic" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.agent_id == "test-agent"
error_message = "Agent ID should be set correctly"
}
assert {
condition = var.workdir == "/home/coder"
error_message = "Workdir should be set correctly"
}
assert {
condition = var.install_codex == true
error_message = "install_codex should default to true"
}
assert {
condition = var.install_agentapi == true
error_message = "install_agentapi should default to true"
}
assert {
condition = var.report_tasks == true
error_message = "report_tasks should default to true"
}
assert {
condition = var.continue == true
error_message = "continue should default to true"
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_codex_with_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_aibridge = true
}
assert {
condition = var.enable_aibridge == true
error_message = "enable_aibridge should be set to true"
}
}
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
openai_api_key = "test-key"
enable_aibridge = false
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should be false"
}
assert {
condition = coder_env.openai_api_key.value == "test-key"
error_message = "OpenAI API key should be set correctly"
}
}
run "test_custom_options" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder/project"
openai_api_key = "test-key"
order = 5
group = "ai-tools"
icon = "/icon/custom.svg"
web_app_display_name = "Custom Codex"
cli_app = true
cli_app_display_name = "Codex Terminal"
subdomain = true
report_tasks = false
continue = false
codex_model = "gpt-4o"
codex_version = "0.1.0"
agentapi_version = "v0.12.0"
}
assert {
condition = var.order == 5
error_message = "Order should be set to 5"
}
assert {
condition = var.group == "ai-tools"
error_message = "Group should be set to 'ai-tools'"
}
assert {
condition = var.icon == "/icon/custom.svg"
error_message = "Icon should be set to custom icon"
}
assert {
condition = var.cli_app == true
error_message = "cli_app should be enabled"
}
assert {
condition = var.subdomain == true
error_message = "subdomain should be enabled"
}
assert {
condition = var.report_tasks == false
error_message = "report_tasks should be disabled"
}
assert {
condition = var.continue == false
error_message = "continue should be disabled"
}
assert {
condition = var.codex_model == "gpt-4o"
error_message = "codex_model should be set to 'gpt-4o'"
}
}
run "test_no_api_key_no_aibridge" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.openai_api_key == ""
error_message = "openai_api_key should be empty when not provided"
}
assert {
condition = var.enable_aibridge == false
error_message = "enable_aibridge should default to false"
}
}
@@ -93,10 +93,14 @@ function install_codex() {
write_minimal_default_config() {
local config_path="$1"
ARG_DEFAULT_PROFILE=""
ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
ARG_DEFAULT_PROFILE='profile = "aibridge"'
ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"'
fi
if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then
ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\""
fi
cat << EOF > "$config_path"
@@ -104,13 +108,17 @@ write_minimal_default_config() {
sandbox_mode = "workspace-write"
approval_policy = "never"
preferred_auth_method = "apikey"
${ARG_DEFAULT_PROFILE}
${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
[sandbox_workspace_write]
network_access = true
[notice.model_migrations]
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
[projects."${ARG_CODEX_START_DIRECTORY}"]
trust_level = "trusted"
EOF
}
@@ -155,7 +155,7 @@ setup_workdir() {
build_codex_args() {
CODEX_ARGS=()
if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then
if [[ -n "${ARG_CODEX_MODEL}" ]]; then
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
fi
@@ -210,7 +210,16 @@ capture_session_id() {
start_codex() {
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
# enable_boundary=true. It points to a wrapper script that runs the command
# through coder boundary, sandboxing only the agent process.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
printf "Starting with coder boundary enabled\n"
agentapi server --type codex --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
else
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
fi
capture_session_id
}
+39 -6
View File
@@ -3,7 +3,7 @@ display_name: Copilot CLI
description: GitHub Copilot CLI agent for AI-powered terminal assistance
icon: ../../../../.icons/github.svg
verified: false
tags: [agent, copilot, ai, github, tasks]
tags: [agent, copilot, ai, github, tasks, aibridge]
---
# Copilot
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.3.0"
version = "0.4.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.3.0"
version = "0.4.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.3.0"
version = "0.4.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.3.0"
version = "0.4.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.3.0"
version = "0.4.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
@@ -164,6 +164,39 @@ module "copilot" {
}
```
### Usage with AI Bridge Proxy
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance.
The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.4.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url
aibridge_proxy_cert_path = module.aibridge-proxy.cert_path
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication.
> [!IMPORTANT]
> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment.
> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time.
## Authentication
The module supports multiple authentication methods (in priority order):
@@ -234,3 +234,116 @@ run "app_slug_is_consistent" {
error_message = "module_dir_name should be '.copilot-module'"
}
}
run "aibridge_proxy_defaults" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_aibridge_proxy == false
error_message = "enable_aibridge_proxy should default to false"
}
assert {
condition = var.aibridge_proxy_auth_url == null
error_message = "aibridge_proxy_auth_url should default to null"
}
assert {
condition = var.aibridge_proxy_cert_path == null
error_message = "aibridge_proxy_cert_path should default to null"
}
}
run "aibridge_proxy_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge-proxy"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com"
error_message = "AI Bridge Proxy auth URL should match the input variable"
}
assert {
condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "AI Bridge Proxy cert path should match the input variable"
}
}
run "aibridge_proxy_validation_missing_proxy_auth_url" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = ""
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_validation_missing_cert_path" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder"
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = ""
}
expect_failures = [
var.enable_aibridge_proxy,
]
}
run "aibridge_proxy_with_copilot_config" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
copilot_model = "gpt-5"
github_token = "ghp_test123"
allow_all_tools = true
enable_aibridge_proxy = true
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
}
assert {
condition = var.enable_aibridge_proxy == true
error_message = "AI Bridge Proxy should be enabled"
}
assert {
condition = length(resource.coder_env.github_token) == 1
error_message = "github_token environment variable should be set alongside proxy"
}
assert {
condition = length(resource.coder_env.copilot_model) == 1
error_message = "copilot_model environment variable should be set alongside proxy"
}
}
+33 -1
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
@@ -173,6 +173,35 @@ variable "post_install_script" {
default = null
}
variable "enable_aibridge_proxy" {
type = bool
description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy"
default = false
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0)
error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true."
}
validation {
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0)
error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true."
}
}
variable "aibridge_proxy_auth_url" {
type = string
description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module."
default = null
sensitive = true
}
variable "aibridge_proxy_cert_path" {
type = string
description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -279,6 +308,9 @@ module "agentapi" {
ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
ARG_RESUME_SESSION='${var.resume_session}' \
ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \
ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \
ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \
/tmp/start.sh
EOT
@@ -22,6 +22,9 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-}
ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-}
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true}
ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false}
ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-}
ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-}
validate_copilot_installation() {
if ! command_exists copilot; then
@@ -118,6 +121,48 @@ setup_github_authentication() {
return 0
}
setup_aibridge_proxy() {
if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then
return 0
fi
echo "Setting up AI Bridge Proxy..."
# Wait for the aibridge-proxy module to finish.
# Uses startup coordination to block until aibridge-proxy-setup signals completion.
if command -v coder > /dev/null 2>&1; then
coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true
coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true
trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided."
exit 1
fi
if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided."
exit 1
fi
if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH."
echo " Ensure the aibridge-proxy module has successfully completed setup."
exit 1
fi
# Set proxy environment variables scoped to this process tree only.
# These are inherited by the agentapi/copilot process below,
# but do not affect other workspace processes, avoiding routing
# unnecessary traffic through the proxy.
export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL"
export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH"
echo "✓ AI Bridge Proxy configured"
echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH"
}
start_agentapi() {
echo "Starting in directory: $ARG_WORKDIR"
cd "$ARG_WORKDIR"
@@ -157,5 +202,6 @@ start_agentapi() {
}
setup_github_authentication
setup_aibridge_proxy
validate_copilot_installation
start_agentapi
@@ -0,0 +1,57 @@
---
display_name: ttyd
description: Share a terminal command over the web via a Coder app
icon: ../../../../.icons/terminal.svg
verified: true
tags: [terminal, web, ttyd]
---
# ttyd
Run any command and expose it as a web-based terminal via [ttyd](https://github.com/tsl0922/ttyd). Each connection spawns a new process for the configured command. The terminal is accessible as a Coder app in the workspace UI.
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "bash"
}
```
## Examples
### Custom command
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
display_name = "Shared Terminal"
command = "tmux new-session -A -s main"
share = "authenticated"
}
```
### Readonly with custom ttyd options
```tf
module "ttyd" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/ttyd/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
command = "tail -f /var/log/app.log"
writable = false
additional_args = "-t fontSize=18"
}
```
## Session Behavior
By default, each browser tab that opens the ttyd app spawns a **new process** for the configured command. Closing the tab kills that process.
To get a **persistent, shared session** that survives tab closes and allows multiple viewers, use tmux as the command (see example above). This requires tmux to be installed in the workspace image.
@@ -0,0 +1,112 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
type scriptOutput,
testRequiredVariables,
} from "~test";
function testBaseLine(output: scriptOutput) {
expect(output.exitCode).toBe(0);
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Installing ttyd");
expect(stdout).toContain("Installation complete!");
expect(stdout).toContain("Starting ttyd in background...");
}
describe("ttyd", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
it("runs with bash", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with custom command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "htop",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("htop");
}, 30000);
it("runs with writable=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
writable: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
agent_name: "main",
subdomain: "false",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
}, 30000);
it("runs with additional_args", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
command: "bash",
additional_args: "-t fontSize=18",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
expect(output.stdout.join("\n")).toContain("fontSize=18");
}, 30000);
});
+165
View File
@@ -0,0 +1,165 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "ttyd"
}
variable "display_name" {
type = string
description = "The display name for the ttyd application."
default = "Web Terminal"
}
variable "port" {
type = number
description = "The port to run ttyd on."
default = 7681
}
variable "command" {
type = string
description = "The command for ttyd to run (e.g., bash, fish, htop)."
}
variable "writable" {
type = bool
description = "Allow clients to write to the terminal."
default = true
}
variable "max_clients" {
type = number
description = "Maximum number of concurrent clients (0 for unlimited)."
default = 0
}
variable "additional_args" {
type = string
description = "Additional arguments to pass to ttyd."
default = ""
}
variable "log_path" {
type = string
description = "The path to log ttyd output to. Defaults to ~/.local/state/ttyd/ttyd.log (XDG-compliant)."
default = ""
}
variable "ttyd_version" {
type = string
description = "The version of ttyd to install."
default = "1.7.7"
}
variable "share" {
type = string
description = "Who can access the app: 'owner' (workspace owner only), 'authenticated' (logged-in users), or 'public' (anyone)."
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via its own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are "tab" and "slim-window" (default).
"tab" opens in a new tab in the same browser window.
"slim-window" opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
resource "coder_script" "ttyd" {
agent_id = var.agent_id
display_name = var.display_name
icon = "/icon/terminal.svg"
script = templatefile("${path.module}/run.sh", {
PORT = var.port,
COMMAND = var.command,
WRITABLE = var.writable,
MAX_CLIENTS = var.max_clients,
ADDITIONAL_ARGS = var.additional_args,
LOG_PATH = local.log_path,
VERSION = var.ttyd_version,
BASE_PATH = local.base_path,
})
run_on_start = true
}
resource "coder_app" "ttyd" {
count = var.command != "" ? 1 : 0
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}${local.base_path}/"
icon = "/icon/terminal.svg"
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
healthcheck {
url = "http://localhost:${var.port}${local.base_path}/token"
interval = 5
threshold = 6
}
}
locals {
base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
log_path = var.log_path != "" ? var.log_path : "~/.local/state/ttyd/ttyd.log"
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
BOLD='\033[[0;1m'
if command -v ttyd &> /dev/null; then
printf "%sFound existing ttyd installation\n\n" "$${BOLD}"
else
printf "%sInstalling ttyd %s\n\n" "$${BOLD}" "${VERSION}"
ARCH=$(uname -m)
# shellcheck disable=SC2195
case "$${ARCH}" in
x86_64) BINARY="ttyd.x86_64" ;;
aarch64) BINARY="ttyd.aarch64" ;;
armv7l) BINARY="ttyd.armhf" ;;
armv6l) BINARY="ttyd.arm" ;;
*)
echo "ERROR: Unsupported architecture: $${ARCH}" >&2
exit 1
;;
esac
BIN_DIR="$${HOME}/.local/bin"
mkdir -p "$${BIN_DIR}"
export PATH="$${BIN_DIR}:$${PATH}"
TTYD_BIN="$${BIN_DIR}/ttyd"
LOCK_DIR="/tmp/ttyd-install.lock"
if [[ ! -f "$${TTYD_BIN}" ]]; then
if mkdir "$${LOCK_DIR}" 2> /dev/null; then
if [[ ! -f "$${TTYD_BIN}" ]]; then
DOWNLOAD_URL="https://github.com/tsl0922/ttyd/releases/download/${VERSION}/$${BINARY}"
printf "Downloading ttyd from %s\n" "$${DOWNLOAD_URL}"
curl -fsSL "$${DOWNLOAD_URL}" -o "$${TTYD_BIN}.tmp"
chmod +x "$${TTYD_BIN}.tmp"
mv "$${TTYD_BIN}.tmp" "$${TTYD_BIN}"
fi
rmdir "$${LOCK_DIR}" 2> /dev/null || true
else
printf "Waiting for ttyd installation to complete...\n"
while [[ -d "$${LOCK_DIR}" ]] && [[ ! -f "$${TTYD_BIN}" ]]; do
sleep 0.5
done
fi
fi
printf "Installation complete!\n\n"
fi
if [[ -z "${COMMAND}" ]]; then
printf "No command specified, skipping ttyd startup.\n"
exit 0
fi
ARGS="-p ${PORT}"
if [[ "${WRITABLE}" = "true" ]]; then
ARGS="$${ARGS} -W"
fi
if [[ "${MAX_CLIENTS}" -gt 0 ]] 2> /dev/null; then
ARGS="$${ARGS} -m ${MAX_CLIENTS}"
fi
if [[ -n "${BASE_PATH}" ]]; then
ARGS="$${ARGS} -b ${BASE_PATH}"
fi
if [[ -n "${ADDITIONAL_ARGS}" ]]; then
ARGS="$${ARGS} ${ADDITIONAL_ARGS}"
fi
TTYD_LOG_PATH="${LOG_PATH}"
TTYD_LOG_PATH="$${TTYD_LOG_PATH/#\~/$${HOME}}"
TTYD_LOG_DIR="$${TTYD_LOG_PATH%/*}"
mkdir -p "$${TTYD_LOG_DIR}"
printf "Starting ttyd in background...\n"
printf "Running: ttyd %s -- %s\n\n" "$${ARGS}" "${COMMAND}"
# shellcheck disable=SC2086
ttyd $${ARGS} -- ${COMMAND} >> "$${TTYD_LOG_PATH}" 2>&1 &
printf "Logs at %s\n" "$${TTYD_LOG_PATH}"
+43 -3
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.2.0"
version = "2.3.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -67,8 +67,7 @@ module "agentapi" {
AgentAPI can save and restore conversation state across workspace restarts.
This is disabled by default and requires agentapi binary >= v0.12.0.
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
To enable:
@@ -89,6 +88,47 @@ module "agentapi" {
}
```
## Boundary (Network Filtering)
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
variable that points to a wrapper script. Agent modules should use this prefix in their
start scripts to run the agent process through boundary.
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
for configuration details.
To enable:
```tf
module "agentapi" {
# ... other config
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
# Optional: install boundary binary instead of using coder subcommand
# use_boundary_directly        = true
# boundary_version              = "0.6.0"
# compile_boundary_from_source  = false
}
```
### Contract for agent modules
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
as an environment variable pointing to a wrapper script. Agent module start scripts
should check for this variable and use it to prefix the agent command:
```bash
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
else
agentapi server -- my-agent "${ARGS[@]}" &
fi
```
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
## For module developers
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
@@ -613,4 +613,109 @@ describe("agentapi", async () => {
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
});
});
describe("boundary", async () => {
test("boundary-disabled-by-default", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Config file should NOT exist when boundary is disabled
const configCheck = await execContainer(id, [
"bash",
"-c",
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
]);
expect(configCheck.stdout.trim()).toBe("missing");
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
});
test("boundary-enabled", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config to the path before running the module
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
allowlist:
- "domain=api.example.com"
EOF`,
]);
// Add mock coder binary for boundary setup
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: `#!/bin/bash
if [ "$1" = "boundary" ]; then
shift; shift; exec "$@"
fi
echo "mock coder"`,
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
// Verify the config file exists at the specified path
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
expect(config).toContain("jail_type: landjail");
expect(config).toContain("proxy_port: 8087");
expect(config).toContain("domain=api.example.com");
// AGENTAPI_BOUNDARY_PREFIX should be exported
const mockLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
// E2E: start script should have used the wrapper
const startLog = await readFileContainer(
id,
"/home/coder/test-agentapi-start.log",
);
expect(startLog).toContain("Starting with boundary:");
});
test("boundary-enabled-no-coder-binary", async () => {
const { id } = await setup({
moduleVariables: {
enable_boundary: "true",
boundary_config_path: "/tmp/test-boundary.yaml",
},
});
// Write boundary config
await execContainer(id, [
"bash",
"-c",
`cat > /tmp/test-boundary.yaml <<'EOF'
jail_type: landjail
proxy_port: 8087
log_level: warn
EOF`,
]);
// Remove coder binary to simulate it not being available
await execContainer(
id,
[
"bash",
"-c",
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
],
["--user", "root"],
);
const resp = await execModuleScript(id);
// Script should fail because coder binary is required
expect(resp.exitCode).not.toBe(0);
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
expect(scriptLog).toContain("Boundary cannot be enabled");
});
});
});
+45
View File
@@ -164,6 +164,36 @@ variable "module_dir_name" {
description = "Name of the subdirectory in the home directory for module files."
}
variable "enable_boundary" {
type = bool
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
default = false
}
variable "boundary_config_path" {
type = string
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
default = ""
}
variable "boundary_version" {
type = string
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_boundary_from_source" {
type = bool
description = "Whether to compile boundary from source instead of using the official install script."
default = false
}
variable "use_boundary_directly" {
type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
@@ -182,6 +212,13 @@ variable "pid_file_path" {
default = ""
}
resource "coder_env" "boundary_config" {
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
agent_id = var.agent_id
name = "BOUNDARY_CONFIG"
value = var.boundary_config_path
}
locals {
# we always trim the slash for consistency
workdir = trimsuffix(var.folder, "/")
@@ -200,6 +237,7 @@ locals {
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
lib_script = file("${path.module}/scripts/lib.sh")
boundary_script = file("${path.module}/scripts/boundary.sh")
}
resource "coder_script" "agentapi" {
@@ -214,6 +252,9 @@ resource "coder_script" "agentapi" {
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
chmod +x /tmp/main.sh
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
chmod +x /tmp/agentapi-boundary.sh
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
@@ -228,6 +269,10 @@ resource "coder_script" "agentapi" {
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
ARG_STATE_FILE_PATH='${var.state_file_path}' \
ARG_PID_FILE_PATH='${var.pid_file_path}' \
@@ -0,0 +1,95 @@
#!/bin/bash
# boundary.sh - Boundary installation and setup for agentapi module.
# Sourced by main.sh when ENABLE_BOUNDARY=true.
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
validate_boundary_subcommand() {
if command_exists coder; then
if coder boundary --help > /dev/null 2>&1; then
return 0
else
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
exit 1
fi
else
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
exit 1
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [ -d boundary ]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Set up boundary: install, write config, create wrapper script.
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
setup_boundary() {
local module_path="$1"
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Determine which boundary command to use and create wrapper script
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary -- "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$module_path/coder-no-caps"
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
exit 1
fi
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
WRAPPER_EOF
fi
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
}
@@ -16,6 +16,10 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
@@ -109,9 +113,18 @@ export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
# Set up boundary if enabled
export AGENTAPI_BOUNDARY_PREFIX=""
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
# shellcheck source=boundary.sh
source /tmp/agentapi-boundary.sh
setup_boundary "$module_path"
fi
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
# Only set state env vars when persistence is enabled and the binary supports
# it. State persistence requires agentapi >= v0.12.0.
@@ -31,6 +31,15 @@ for (const v of [
);
}
}
// Log boundary env vars.
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
if (process.env[v]) {
fs.appendFileSync(
"/home/coder/agentapi-mock.log",
`\n${v}: ${process.env[v]}`,
);
}
}
// Write PID file for shutdown script.
if (process.env.AGENTAPI_PID_FILE) {
+13 -3
View File
@@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
export AGENTAPI_CHAT_BASE_PATH
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
# Use boundary wrapper if configured by agentapi module.
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
# and points to a wrapper script that runs the command through coder boundary.
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
> "$log_file_path" 2>&1
else
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
> "$log_file_path" 2>&1
fi
@@ -0,0 +1,89 @@
---
display_name: AI Bridge Proxy
description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy.
icon: ../../../../.icons/coder.svg
verified: true
tags: [helper, aibridge]
---
# AI Bridge Proxy
This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy).
It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
}
```
> [!NOTE]
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment.
## How it works
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking.
Any process with the proxy environment variables set will route **all** its traffic through the proxy.
This module **does not** set proxy environment variables globally on the workspace.
Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy.
> [!WARNING]
> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error.
> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details.
## Startup Coordination
When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)),
the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting.
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment:
```hcl
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_AGENT_SOCKET_SERVER_ENABLED=true",
]
```
> [!NOTE]
> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30.
> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time.
## Examples
### Custom certificate path
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem"
}
```
### Proxy with custom port
For deployments where the proxy is accessed directly on a configured port.
See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines.
```tf
module "aibridge-proxy" {
source = "registry.coder.com/coder/aibridge-proxy/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
proxy_url = "http://internal-proxy:8888"
}
```
@@ -0,0 +1,254 @@
import { serve } from "bun";
import {
afterEach,
beforeAll,
describe,
expect,
it,
setDefaultTimeout,
} from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
const FAKE_CERT =
"-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n";
// Runs terraform apply to render the setup script, then starts a Docker
// container where we can execute it against a mock server.
const setupContainer = async (vars: Record<string, string> = {}) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
...vars,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("lorello/alpine-bash");
registerCleanup(async () => {
await removeContainer(id);
});
return { id, instance };
};
// Starts a mock HTTP server that simulates the Coder API certificate endpoint.
// Returns the server and its base URL.
const setupServer = (handler: (req: Request) => Response) => {
const server = serve({
fetch: handler,
port: 0,
});
registerCleanup(async () => {
server.stop();
});
return {
server,
// Base URL without trailing slash
url: server.url.toString().slice(0, -1),
};
};
setDefaultTimeout(30 * 1000);
describe("aibridge-proxy", () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
// Verify that agent_id and proxy_url are required.
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
it("downloads the CA certificate successfully", async () => {
let receivedToken = "";
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
receivedToken = req.headers.get("Coder-Session-Token") || "";
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
// Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server.
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=test-session-token-123",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem",
);
// Verify the cert was written to the default path.
const certContent = await execContainer(id, [
"cat",
"/tmp/aibridge-proxy/ca-cert.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
// Verify the session token was sent in the request header.
expect(receivedToken).toBe("test-session-token-123");
});
it("fails when the server is unreachable", async () => {
const { id, instance } = await setupContainer();
// Port 9999 has nothing listening, so curl will fail to connect.
const exec = await execContainer(id, [
"env",
"ACCESS_URL=http://localhost:9999",
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: could not connect to",
);
});
it("fails when the server returns a non-200 status", async () => {
const { url } = setupServer(() => {
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: unexpected response",
);
});
it("fails when the server returns an empty response", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response("", { status: 200 });
}
return new Response("not found", { status: 404 });
});
const { id, instance } = await setupContainer();
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).not.toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy setup failed: downloaded certificate is empty.",
);
});
it("saves the certificate to a custom path", async () => {
const { url } = setupServer((req) => {
const reqUrl = new URL(req.url);
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
return new Response(FAKE_CERT, {
status: 200,
headers: { "Content-Type": "application/x-pem-file" },
});
}
return new Response("not found", { status: 404 });
});
// Pass a custom cert_path to terraform apply so the script uses it.
const { id, instance } = await setupContainer({
cert_path: "/tmp/custom/certs/proxy-ca.pem",
});
const exec = await execContainer(id, [
"env",
`ACCESS_URL=${url}`,
"SESSION_TOKEN=mock-token",
"bash",
"-c",
instance.script,
]);
expect(exec.exitCode).toBe(0);
expect(exec.stdout).toContain(
"AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem",
);
const certContent = await execContainer(id, [
"cat",
"/tmp/custom/certs/proxy-ca.pem",
]);
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
});
it("does not create global proxy env vars via coder_env", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
proxy_url: "https://aiproxy.example.com",
});
// Proxy env vars should NOT be set globally via coder_env.
// They are intended to be scoped to specific tool processes.
const proxyEnvVarNames = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
];
const proxyEnvVars = state.resources.filter(
(r) =>
r.type === "coder_env" &&
r.instances.some((i) =>
proxyEnvVarNames.includes(i.attributes.name as string),
),
);
expect(proxyEnvVars.length).toBe(0);
});
});
@@ -0,0 +1,81 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "proxy_url" {
type = string
description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)."
validation {
condition = can(regex("^https?://", var.proxy_url))
error_message = "proxy_url must start with http:// or https://."
}
}
variable "cert_path" {
type = string
description = "Absolute path where the AI Bridge Proxy CA certificate will be saved."
default = "/tmp/aibridge-proxy/ca-cert.pem"
validation {
condition = startswith(var.cert_path, "/")
error_message = "cert_path must be an absolute path."
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
# Build the proxy URL with Coder authentication embedded.
# AI Bridge Proxy expects the Coder session token as the password
# in basic auth: http://coder:<token>@host:port
proxy_auth_url = replace(
var.proxy_url,
"://",
"://coder:${data.coder_workspace_owner.me.session_token}@"
)
}
# These outputs are intended to be consumed by tool-specific modules,
# to set proxy environment variables scoped to their process, rather than globally.
output "proxy_auth_url" {
description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:<token>@host:port)."
value = local.proxy_auth_url
sensitive = true
}
output "cert_path" {
description = "Path to the downloaded AI Bridge Proxy CA certificate."
value = var.cert_path
}
# Downloads the CA certificate from the Coder deployment.
# This runs on workspace start but does not block login, if the script
# fails, the workspace remains usable and the error is visible in the build logs.
# Tools that depend on the proxy will fail until the certificate is available.
resource "coder_script" "aibridge_proxy_setup" {
agent_id = var.agent_id
display_name = "AI Bridge Proxy Setup"
icon = "/icon/coder.svg"
run_on_start = true
start_blocks_login = false
script = templatefile("${path.module}/scripts/setup.sh", {
CERT_PATH = var.cert_path,
ACCESS_URL = data.coder_workspace.me.access_url,
SESSION_TOKEN = data.coder_workspace_owner.me.session_token,
})
}
@@ -0,0 +1,210 @@
run "test_aibridge_proxy_basic" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = var.agent_id == "test-agent-id"
error_message = "Agent ID should match the input variable"
}
assert {
condition = var.proxy_url == "https://aiproxy.example.com"
error_message = "Proxy URL should match the input variable"
}
assert {
condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem"
}
}
run "test_aibridge_proxy_empty_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = ""
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_invalid_url_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "aiproxy.example.com"
}
expect_failures = [
var.proxy_url,
]
}
run "test_aibridge_proxy_url_formats" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should be a valid URL with scheme"
}
}
run "test_aibridge_proxy_https_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com:8443"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTPS with custom port"
}
}
run "test_aibridge_proxy_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
assert {
condition = can(regex("^https?://", var.proxy_url))
error_message = "Proxy URL should support HTTP with custom port"
}
}
run "test_aibridge_proxy_empty_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = ""
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_relative_cert_path_validation" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "relative/path/ca-cert.pem"
}
expect_failures = [
var.cert_path,
]
}
run "test_aibridge_proxy_custom_cert_path" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
cert_path = "/home/coder/.certs/ca-cert.pem"
}
assert {
condition = var.cert_path == "/home/coder/.certs/ca-cert.pem"
error_message = "cert_path should match the input variable"
}
}
run "test_aibridge_proxy_script" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
assert {
condition = coder_script.aibridge_proxy_setup.run_on_start == true
error_message = "Script should run on start"
}
assert {
condition = coder_script.aibridge_proxy_setup.start_blocks_login == false
error_message = "Script should not block login"
}
assert {
condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup"
error_message = "Script display name should be 'AI Bridge Proxy Setup'"
}
}
run "test_aibridge_proxy_auth_url_https" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "https://aiproxy.example.com"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com"
error_message = "proxy_auth_url should contain the mocked session token"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
run "test_aibridge_proxy_auth_url_http_with_port" {
command = plan
variables {
agent_id = "test-agent-id"
proxy_url = "http://internal-proxy:8888"
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888"
error_message = "proxy_auth_url should preserve the port"
}
assert {
condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
error_message = "cert_path output should match the default"
}
}
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
if [ -z "$CERT_PATH" ]; then
CERT_PATH="${CERT_PATH}"
fi
if [ -z "$ACCESS_URL" ]; then
ACCESS_URL="${ACCESS_URL}"
fi
if [ -z "$SESSION_TOKEN" ]; then
SESSION_TOKEN="${SESSION_TOKEN}"
fi
set -euo pipefail
# Signal startup coordination.
# The trap ensures 'complete' is always called (even on failure) so dependent
# scripts unblock promptly and can check for the certificate themselves.
if command -v coder > /dev/null 2>&1; then
coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true
trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT
fi
if [ -z "$ACCESS_URL" ]; then
echo "Error: Coder access URL is not set."
exit 1
fi
if [ -z "$SESSION_TOKEN" ]; then
echo "Error: Coder session token is not set."
exit 1
fi
if ! command -v curl > /dev/null; then
echo "Error: curl is not installed."
exit 1
fi
echo "--------------------------------"
echo "AI Bridge Proxy Setup"
printf "Certificate path: %s\n" "$CERT_PATH"
printf "Access URL: %s\n" "$ACCESS_URL"
echo "--------------------------------"
CERT_DIR=$(dirname "$CERT_PATH")
mkdir -p "$CERT_DIR"
CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem"
echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..."
# Download the certificate with a 5s connection timeout and 10s total timeout
# to avoid the script hanging indefinitely.
if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \
--connect-timeout 5 \
--max-time 10 \
-H "Coder-Session-Token: $SESSION_TOKEN" \
"$CERT_URL"); then
echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ "$HTTP_STATUS" -ne 200 ]; then
echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)."
echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace."
rm -f "$CERT_PATH"
exit 1
fi
if [ ! -s "$CERT_PATH" ]; then
echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty."
rm -f "$CERT_PATH"
exit 1
fi
echo "AI Bridge Proxy CA certificate saved to $CERT_PATH"
echo "✅ AI Bridge Proxy setup complete."
+22 -9
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -36,6 +36,19 @@ module "claude-code" {
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
## State Persistence
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
To disable:
```tf
module "claude-code" {
# ... other config
enable_state_persistence = false
}
```
## Examples
### Usage with Agent Boundaries
@@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
@@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
@@ -97,7 +110,7 @@ data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
@@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
@@ -198,7 +211,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
version = "4.8.1"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+26 -19
View File
@@ -67,7 +67,7 @@ variable "cli_app_display_name" {
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Claude Code."
description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)."
default = null
}
@@ -261,6 +261,12 @@ variable "enable_aibridge" {
}
}
variable "enable_state_persistence" {
type = bool
description = "Enable AgentAPI conversation state persistence across restarts."
default = true
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id
@@ -356,25 +362,26 @@ locals {
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
version = "2.2.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = var.web_app_display_name
folder = local.workdir
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
agentapi_subdomain = var.subdomain
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
enable_state_persistence = var.enable_state_persistence
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -387,6 +387,36 @@ run "test_aibridge_disabled_with_api_key" {
}
}
run "test_enable_state_persistence_default" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
}
assert {
condition = var.enable_state_persistence == true
error_message = "enable_state_persistence should default to true"
}
}
run "test_disable_state_persistence" {
command = plan
variables {
agent_id = "test-agent"
workdir = "/home/coder"
enable_state_persistence = false
}
assert {
condition = var.enable_state_persistence == false
error_message = "enable_state_persistence should be false when explicitly disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
@@ -88,7 +88,7 @@ TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
get_project_dir() {
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
echo "$HOME/.claude/projects/${workdir_normalized}"
}
@@ -14,8 +14,9 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.34"
agent_id = coder_agent.example.id
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
start_blocks_login = false
}
```
@@ -14,10 +14,17 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
resource "coder_script" "devcontainers-cli" {
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
variable "start_blocks_login" {
type = bool
default = false
description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup."
}
resource "coder_script" "devcontainers-cli" {
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
start_blocks_login = var.start_blocks_login
}
+6 -6
View File
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
}
```
@@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
}
```
@@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,14 +54,14 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.3.2"
version = "1.4.0"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+35 -1
View File
@@ -62,7 +62,41 @@ describe("dotfiles", async () => {
agent_id: "foo",
coder_parameter_order: order.toString(),
});
expect(state.resources).toHaveLength(3);
const parameters = state.resources.filter(
(r) => r.type === "coder_parameter",
);
for (const param of parameters) {
expect(param.instances[0].attributes.order).toBe(order);
}
});
it("set custom dotfiles_branch", async () => {
const branch = "develop";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_branch: branch,
});
expect(state.resources).toHaveLength(2);
expect(state.resources[0].instances[0].attributes.order).toBe(order);
const scriptResource = state.resources.find(
(r) => r.type === "coder_script",
);
expect(scriptResource?.instances[0].attributes.script).toContain(
`DOTFILES_BRANCH="${branch}"`,
);
});
it("default dotfiles_branch creates parameter", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
expect(state.resources).toHaveLength(3);
const branchParameter = state.resources.find(
(r) =>
r.type === "coder_parameter" &&
r.instances[0].attributes.name === "dotfiles_branch",
);
expect(branchParameter).toBeDefined();
expect(branchParameter?.instances[0].attributes.default).toBeNull();
});
});
+32
View File
@@ -46,6 +46,12 @@ variable "default_dotfiles_uri" {
}
}
variable "default_dotfiles_branch" {
type = string
description = "The default dotfiles branch if the workspace user does not provide one"
default = ""
}
variable "dotfiles_uri" {
type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
@@ -61,6 +67,17 @@ variable "dotfiles_uri" {
}
}
variable "dotfiles_branch" {
type = string
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
default = null
validation {
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
}
}
variable "user" {
type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
@@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" {
}
}
data "coder_parameter" "dotfiles_branch" {
count = var.dotfiles_branch == null ? 1 : 0
type = "string"
name = "dotfiles_branch"
display_name = "Dotfiles Branch"
order = var.coder_parameter_order
default = var.default_dotfiles_branch
description = "The branch to use for the dotfiles repository"
mutable = true
icon = "/icon/dotfiles.svg"
}
locals {
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
user = var.user != null ? var.user : ""
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
@@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" {
script = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
display_name = "Dotfiles"
@@ -136,6 +167,7 @@ resource "coder_app" "dotfiles" {
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user,
DOTFILES_BRANCH : local.dotfiles_branch,
POST_CLONE_SCRIPT : local.encoded_post_clone_script
})
}
+16 -3
View File
@@ -4,6 +4,7 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
@@ -24,10 +25,18 @@ if [ -n "$${DOTFILES_URI// }" ]; then
DOTFILES_USER="$USER"
fi
echo "✨ Applying dotfiles for user $DOTFILES_USER"
if [ -n "$DOTFILES_BRANCH" ]; then
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
else
echo "✨ Applying dotfiles for user $DOTFILES_USER"
fi
if [ "$DOTFILES_USER" = "$USER" ]; then
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
if [ -n "$DOTFILES_BRANCH" ]; then
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
else
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
fi
else
if command -v getent > /dev/null 2>&1; then
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
@@ -40,7 +49,11 @@ if [ -n "$${DOTFILES_URI// }" ]; then
fi
CODER_BIN=$(command -v coder)
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
if [ -n "$DOTFILES_BRANCH" ]; then
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
else
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
fi
fi
fi
@@ -0,0 +1,75 @@
---
display_name: JFrog Xray
description: Fetch container image vulnerability scan results from JFrog Xray
icon: ../../../../.icons/jfrog-xray.svg
verified: true
tags: [jfrog, xray]
---
# JFrog Xray
This module fetches vulnerability scan results from JFrog Xray for container images stored in Artifactory. Use the outputs to display security information as workspace metadata.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-local/myapp/backend:v1.0.0"
}
resource "coder_metadata" "xray_scan" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
icon = "/icon/shield.svg"
item {
key = "Image"
value = "docker-local/myapp/backend:v1.0.0"
}
item {
key = "Total Vulnerabilities"
value = module.jfrog_xray.total
}
item {
key = "Critical"
value = module.jfrog_xray.critical
}
item {
key = "High"
value = module.jfrog_xray.high
}
item {
key = "Medium"
value = module.jfrog_xray.medium
}
item {
key = "Low"
value = module.jfrog_xray.low
}
}
```
## Prerequisites
1. Container images must be stored in JFrog Artifactory
2. JFrog Xray must be configured to scan your repositories
3. A valid JFrog access token with Xray read permissions
## Remote Repositories
When scanning images from remote (proxy) repositories, set `use_cache_repo = true`. This is because Artifactory stores cached images in a companion `-cache` repository where Xray indexes the scan results.
```tf
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "https://example.jfrog.io/xray"
xray_token = var.artifactory_access_token
image = "docker-remote/library/nginx:latest"
use_cache_repo = true
}
```
@@ -0,0 +1,244 @@
import { serve } from "bun";
import { describe, expect, it } from "bun:test";
import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test";
describe("jfrog-xray", async () => {
await runTerraformInit(import.meta.dir);
// Mock server simulating a local repo with direct scan results
const mockLocalRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "myapp/backend/v1.0.0",
repo_path: "/myapp/backend/v1.0.0/manifest.json",
size: "50.00 MB",
sec_issues: {
critical: 1,
high: 3,
medium: 5,
low: 10,
total: 19,
},
scans_status: {
overall: {
status: "DONE",
time: "2026-03-04T22:00:02Z",
},
},
violations: 0,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server simulating a remote repo with cache behavior
// Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size)
const mockRemoteRepo = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({
data: [
{
name: "codercom/enterprise-base/ubuntu",
repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json",
size: "0.00 B",
sec_issues: { total: 0 },
scans_status: {
overall: { status: "DONE" },
},
violations: 0,
},
{
name: "codercom/enterprise-base/sha256__abc123def456",
repo_path:
"/codercom/enterprise-base/sha256__abc123def456/manifest.json",
size: "359.33 MB",
sec_issues: {
critical: 2,
high: 6,
medium: 20,
low: 23,
total: 51,
},
scans_status: {
overall: { status: "DONE" },
},
violations: 2,
},
],
offset: 0,
});
return createJSONResponse({});
},
port: 0,
});
// Mock server returning empty results (image not scanned)
const mockEmptyResults = serve({
fetch: (req) => {
const url = new URL(req.url);
if (url.pathname === "/xray/api/v1/system/version")
return createJSONResponse({
xray_version: "3.80.0",
xray_revision: "abc123",
});
if (url.pathname === "/xray/api/v1/artifacts")
return createJSONResponse({ data: [], offset: -1 });
return createJSONResponse({});
},
port: 0,
});
const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`;
const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`;
const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`;
const getProviderEnv = (url: string) => ({
XRAY_URL: url,
XRAY_ACCESS_TOKEN: "test-token",
});
it("validates required variable: xray_url", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_token: "test-token",
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_url");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_url" is not set');
}
});
it("validates required variable: xray_token", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
image: "docker-local/test/image:latest",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without xray_token");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "xray_token" is not set');
}
});
it("validates required variable: image", async () => {
try {
await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
},
getProviderEnv(localRepoUrl),
);
throw new Error("Expected apply to fail without image");
} catch (ex) {
if (!(ex instanceof Error)) throw new Error("Unknown error");
expect(ex.message).toContain('input variable "image" is not set');
}
});
it("returns vulnerability counts for local repository", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "docker-local/myapp/backend:v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.critical.value).toBe(1);
expect(state.outputs.high.value).toBe(3);
expect(state.outputs.medium.value).toBe(5);
expect(state.outputs.low.value).toBe(10);
expect(state.outputs.total.value).toBe(19);
});
it("returns zero counts when image has no scan results", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: emptyResultsUrl,
xray_token: "test-token",
image: "docker-local/unscanned/image:latest",
},
getProviderEnv(emptyResultsUrl),
);
expect(state.outputs.critical.value).toBe(0);
expect(state.outputs.high.value).toBe(0);
expect(state.outputs.medium.value).toBe(0);
expect(state.outputs.low.value).toBe(0);
expect(state.outputs.total.value).toBe(0);
});
it("uses cache repo when use_cache_repo is enabled", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: remoteRepoUrl,
xray_token: "test-token",
image: "docker-remote/codercom/enterprise-base:ubuntu",
use_cache_repo: true,
},
getProviderEnv(remoteRepoUrl),
);
// Should find the SHA artifact with actual vulnerabilities
expect(state.outputs.critical.value).toBe(2);
expect(state.outputs.high.value).toBe(6);
expect(state.outputs.medium.value).toBe(20);
expect(state.outputs.low.value).toBe(23);
expect(state.outputs.total.value).toBe(51);
expect(state.outputs.violations.value).toBe(2);
expect(state.outputs.artifact_name.value).toContain("sha256__");
});
it("allows custom repo and repo_path override", async () => {
const state = await runTerraformApply(
import.meta.dir,
{
xray_url: localRepoUrl,
xray_token: "test-token",
image: "ignored/path:tag",
repo: "docker-local",
repo_path: "/myapp/backend/v1.0.0",
},
getProviderEnv(localRepoUrl),
);
expect(state.outputs.total.value).toBe(19);
});
});
+135
View File
@@ -0,0 +1,135 @@
terraform {
required_version = ">= 1.0"
required_providers {
xray = {
source = "jfrog/xray"
version = ">= 2.0"
}
}
}
provider "xray" {
url = var.xray_url
access_token = var.xray_token
}
variable "xray_url" {
description = "The URL of your JFrog Xray instance (e.g., https://mycompany.jfrog.io/xray). This should point to the Xray API endpoint, not Artifactory."
type = string
validation {
condition = can(regex("^https?://", var.xray_url))
error_message = "The xray_url must be a valid URL starting with http:// or https://."
}
}
variable "xray_token" {
description = "The access token for authenticating with JFrog Xray. This token needs read permissions on Xray scan results. You can generate one in JFrog Platform under User Management > Access Tokens."
type = string
sensitive = true
}
variable "image" {
description = "The Docker image to check for vulnerabilities, in the format 'repo/path/image:tag'. For example: 'docker-local/myapp/backend:v1.0.0' or 'docker-remote/library/nginx:latest'. The repository name is extracted from the first path segment."
type = string
validation {
condition = length(split("/", var.image)) >= 2
error_message = "The image must include at least a repository and image name (e.g., 'docker-local/myimage:tag')."
}
}
variable "repo" {
description = "Override the repository name extracted from the image path. Use this when your Artifactory repository name differs from the first segment of your image path."
type = string
default = ""
}
variable "repo_path" {
description = "Override the full Xray repository path. Use this for custom path structures that don't follow the standard 'repo/image:tag' format. When set, this takes precedence over automatic path construction."
type = string
default = ""
}
variable "use_cache_repo" {
description = "Set to true when scanning images from remote (proxy) repositories. Remote repositories in Artifactory store cached artifacts in a companion '-cache' repository (e.g., 'docker-remote-cache'), which is where Xray indexes the scan results."
type = bool
default = false
}
locals {
# Parse the image string into components
# Example: "docker-local/myapp/backend:v1.0.0"
# -> repo: "docker-local", image_name: "myapp/backend", tag: "v1.0.0"
image_parts = split("/", var.image)
base_repo = var.repo != "" ? var.repo : local.image_parts[0]
parsed_repo = var.use_cache_repo ? "${local.base_repo}-cache" : local.base_repo
image_path = join("/", slice(local.image_parts, 1, length(local.image_parts)))
image_name = split(":", local.image_path)[0]
image_tag = length(split(":", local.image_path)) > 1 ? split(":", local.image_path)[1] : "latest"
# Construct the Xray query path based on repository type:
# - Local repositories: Query the exact tag path (e.g., /myapp/backend/v1.0.0)
# - Remote repositories: Query by image name only (e.g., /myapp/backend) because
# the Terraform provider only returns the SHA manifest (with actual scan data)
# when querying the broader path
parsed_path = var.repo_path != "" ? var.repo_path : (
var.use_cache_repo ? "/${local.image_name}" : "/${local.image_name}/${local.image_tag}"
)
results = coalesce(try(data.xray_artifacts_scan.image_scan.results, []), [])
# For remote repositories, filter to find the actual scanned image (not tag pointers):
# - Tag manifests have size "0.00 B" (they're just pointers to SHA manifests)
# - SHA manifests have actual size (e.g., "359.33 MB") and contain the real scan data
# For local repositories, there's typically only one result which is the actual image
scanned_images = var.use_cache_repo ? [
for r in local.results : r if r.size != "0.00 B"
] : local.results
# The artifact we'll report scan results for
scan_result = (
length(local.scanned_images) > 0 ? local.scanned_images[0] :
length(local.results) > 0 ? local.results[0] :
null
)
}
data "xray_artifacts_scan" "image_scan" {
repo = local.parsed_repo
repo_path = local.parsed_path
}
output "critical" {
description = "The number of critical severity vulnerabilities found in the image. Critical vulnerabilities typically require immediate attention."
value = try(local.scan_result.sec_issues.critical, 0)
}
output "high" {
description = "The number of high severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.high, 0)
}
output "medium" {
description = "The number of medium severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.medium, 0)
}
output "low" {
description = "The number of low severity vulnerabilities found in the image."
value = try(local.scan_result.sec_issues.low, 0)
}
output "total" {
description = "The total number of vulnerabilities found across all severity levels."
value = try(local.scan_result.sec_issues.total, 0)
}
output "artifact_name" {
description = "The name of the artifact that was scanned, as reported by Xray. For remote repositories, this will be the SHA-based manifest name (e.g., 'myimage/sha256__abc123...')."
value = try(local.scan_result.name, "")
}
output "violations" {
description = "The number of Xray policy violations detected. Violations are triggered when vulnerabilities match rules defined in your Xray security policies."
value = try(local.scan_result.violations, 0)
}
+30 -11
View File
@@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
# Mux
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
@@ -78,19 +78,35 @@ The module parses quoted values, so grouped arguments remain intact.
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
```
### Restart After Mux Exits
Enable automatic restarts after Mux exits, including clean exits and intentional shutdown signals such as `SIGTERM`. The launcher waits for `restart_delay_seconds`, removes `~/.mux/server.lock`, and starts Mux again. Set `max_restart_attempts` to a whole number to stop retrying after a fixed number of restarts, or leave it at `0` for unlimited retries.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
restart_on_kill = true
restart_delay_seconds = 3
max_restart_attempts = 5
}
```
### Custom Port
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
port = 8080
}
@@ -104,7 +120,7 @@ Force a specific package manager instead of auto-detection:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
@@ -118,7 +134,7 @@ Use a private or mirrored npm registry:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
@@ -132,7 +148,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -146,7 +162,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.3.1"
version = "1.4.3"
agent_id = coder_agent.main.id
install = false
}
@@ -163,3 +179,6 @@ module "mux" {
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Falls back to a direct tarball download when no package manager is found
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits
- Set `max_restart_attempts` to a whole-number cap on restart attempts, or leave it at `0` for unlimited retries
+186
View File
@@ -96,6 +96,192 @@ chmod +x /tmp/mux/mux`,
}
}, 60000);
it("logs signal-based exits after startup", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
target_pid="$$"
(
sleep 1
kill -9 "$target_pid"
) &
while true; do
sleep 1
done
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 2"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("shell exit code 137");
expect(log).toContain(
"SIGKILL usually means the process was killed externally or by the OOM killer.",
);
} finally {
await removeContainer(id);
}
}, 60000);
it("restarts after a clean exit when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
mkdir -p "$HOME/.mux"
touch "$HOME/.mux/server.lock"
exit 0
fi
if [ -f "$HOME/.mux/server.lock" ]; then
echo "lock=present"
else
echo "lock=cleaned"
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("mux server exited cleanly.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain(
"Removing /root/.mux/server.lock before restarting mux.",
);
expect(log).toContain("run=2");
expect(log).toContain("lock=cleaned");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("restarts after SIGTERM when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
fi
run_count=$((run_count + 1))
printf '%s' "$run_count" > "$run_count_file"
echo "run=$run_count"
if [ "$run_count" -eq 1 ]; then
kill -TERM $$
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
expect(log).toContain("run=1");
expect(log).toContain("signal TERM (15); shell exit code 143.");
expect(log).toContain(
"Waiting 1 seconds before restarting mux after it exited.",
);
expect(log).toContain("run=2");
expect(log).toContain(
"Reached the max restart attempts limit (1); not restarting mux again.",
);
expect(runCount.trim()).toBe("2");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+31
View File
@@ -49,6 +49,34 @@ variable "log_path" {
default = "/tmp/mux.log"
}
variable "restart_on_kill" {
type = bool
description = "Restart Mux after it exits by waiting briefly, removing the server lock, and launching it again."
default = false
}
variable "restart_delay_seconds" {
type = number
description = "How long to wait before restarting Mux after it exits when restart_on_kill is enabled."
default = 5
validation {
condition = var.restart_delay_seconds >= 0
error_message = "The 'restart_delay_seconds' variable must be greater than or equal to 0."
}
}
variable "max_restart_attempts" {
type = number
description = "Maximum whole-number restart attempts before giving up. Set to 0 for unlimited restarts when restart_on_kill is enabled."
default = 0
validation {
condition = var.max_restart_attempts >= 0 && floor(var.max_restart_attempts) == var.max_restart_attempts
error_message = "The 'max_restart_attempts' variable must be a whole number greater than or equal to 0."
}
}
variable "add_project" {
type = string
description = "Optional path to add/open as a project in Mux on startup."
@@ -171,6 +199,9 @@ resource "coder_script" "mux" {
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
AUTH_TOKEN : local.mux_auth_token,
RESTART_ON_KILL : var.restart_on_kill,
RESTART_DELAY_SECONDS : var.restart_delay_seconds,
MAX_RESTART_ATTEMPTS : var.max_restart_attempts,
PACKAGE_MANAGER : var.package_manager,
REGISTRY_URL : local.registry_url,
})
+123
View File
@@ -93,6 +93,129 @@ run "custom_additional_arguments" {
}
}
run "launcher_logs_external_kills" {
command = plan
variables {
agent_id = "foo"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
error_message = "mux launcher must explain SIGKILL exits in the log"
}
}
run "restart_on_kill_enabled" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"")
error_message = "mux launcher must receive the restart_on_kill setting"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"")
error_message = "mux launcher must receive the configured restart delay"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.")
error_message = "mux launcher must log the restart delay before relaunching"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.")
error_message = "mux launcher must clean up the server lock before relaunching"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128")
error_message = "mux launcher must no longer exclude non-signal exits from restart handling"
}
assert {
condition = !strcontains(resource.coder_script.mux.script, "1|2|15)")
error_message = "mux launcher must no longer exclude intentional signals from restart handling"
}
}
run "restart_on_kill_with_restart_cap" {
command = plan
variables {
agent_id = "foo"
restart_on_kill = true
restart_delay_seconds = 7
max_restart_attempts = 2
}
assert {
condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"")
error_message = "mux launcher must receive the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.")
error_message = "mux launcher must describe the configured restart cap"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.")
error_message = "mux launcher must log when it hits the restart cap"
}
}
run "invalid_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = -1
}
expect_failures = [
var.max_restart_attempts
]
}
run "fractional_max_restart_attempts" {
command = plan
variables {
agent_id = "foo"
max_restart_attempts = 0.5
}
expect_failures = [
var.max_restart_attempts
]
}
run "invalid_restart_delay_seconds" {
command = plan
variables {
agent_id = "foo"
restart_delay_seconds = -1
}
expect_failures = [
var.restart_delay_seconds
]
}
run "custom_version" {
command = plan
+159 -6
View File
@@ -5,16 +5,32 @@ RESET='\033[0m'
MUX_BINARY="${INSTALL_PREFIX}/mux"
function run_mux() {
# Remove stale server lock if present
rm -f "$HOME/.mux/server.lock"
local port_value
local auth_token_value
local restart_on_kill_value
local restart_delay_seconds_value
local max_restart_attempts_value
port_value="${PORT}"
auth_token_value="${AUTH_TOKEN}"
restart_on_kill_value="${RESTART_ON_KILL}"
restart_delay_seconds_value="${RESTART_DELAY_SECONDS}"
max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}"
if [ -z "$port_value" ]; then
port_value="4000"
fi
if [ -z "$restart_delay_seconds_value" ]; then
restart_delay_seconds_value="5"
fi
if [ -z "$max_restart_attempts_value" ]; then
max_restart_attempts_value="0"
fi
mkdir -p "$(dirname "${LOG_PATH}")"
# Build args for mux (POSIX-compatible, avoid bash arrays)
set -- server --port "$port_value"
if [ -n "${ADD_PROJECT}" ]; then
@@ -31,16 +47,153 @@ function run_mux() {
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF
done << EOF_ARGS
$${parsed_additional_arguments}
EOF
EOF_ARGS
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
echo "️ Mux exit details will be appended to ${LOG_PATH} by the launcher."
if [ "$restart_on_kill_value" = true ]; then
echo "️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay."
if [ "$max_restart_attempts_value" = "0" ]; then
echo "️ Automatic restarts are unlimited for every mux exit."
else
echo "️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts."
fi
fi
nohup env \
LOG_PATH="${LOG_PATH}" \
MUX_BINARY="$MUX_BINARY" \
AUTH_TOKEN="$auth_token_value" \
PORT_VALUE="$port_value" \
RESTART_ON_KILL_VALUE="$restart_on_kill_value" \
RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \
MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
signal_name() {
local signal_number="$1"
local resolved_signal
resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)"
if [ -n "$resolved_signal" ]; then
printf '%s' "$resolved_signal"
return 0
fi
printf 'SIG%s' "$signal_number"
}
append_kernel_kill_context() {
local mux_pid="$1"
local kernel_context=""
if command -v dmesg > /dev/null 2>&1; then
kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then
kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
fi
if [ -n "$kernel_context" ]; then
echo "Recent kernel kill context:"
echo "$kernel_context"
else
echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)."
fi
}
cleanup_mux_lock() {
rm -f "$HOME/.mux/server.lock"
}
should_restart_mux() {
[ "$RESTART_ON_KILL_VALUE" = "true" ]
}
log_mux_exit() {
local mux_pid="$1"
local exit_code="$2"
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
if [ "$exit_code" -eq 0 ]; then
echo "[$timestamp] mux server exited cleanly."
return 0
fi
if [ "$exit_code" -gt 128 ]; then
local signal_number=$((exit_code - 128))
local signal_label
signal_label="$(signal_name "$signal_number")"
echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code."
if [ "$signal_number" -eq 9 ]; then
echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer."
append_kernel_kill_context "$mux_pid"
fi
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
return 0
fi
echo "[$timestamp] mux server exited with code $exit_code."
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
}
log_mux_restart_wait() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited."
}
log_mux_restart_cleanup() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux."
}
log_mux_restart_cap_reached() {
local timestamp
timestamp="$(date -Iseconds 2> /dev/null || date)"
echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again."
}
restart_attempt_count=0
while true; do
cleanup_mux_lock
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
mux_pid=$!
wait "$mux_pid"
exit_code=$?
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
if should_restart_mux; then
if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then
log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1
break
fi
restart_attempt_count=$((restart_attempt_count + 1))
log_mux_restart_wait >> "$LOG_PATH" 2>&1
sleep "$RESTART_DELAY_SECONDS_VALUE"
cleanup_mux_lock
log_mux_restart_cleanup >> "$LOG_PATH" 2>&1
continue
fi
break
done
EOF_LAUNCHER
}
# Check if mux is already installed for offline mode
if [ "${OFFLINE}" = true ]; then
if [ -f "$MUX_BINARY" ]; then
@@ -0,0 +1,46 @@
---
display_name: Portable Desktop
description: Install the portabledesktop binary for lightweight Linux desktop sessions.
icon: ../../../../.icons/desktop.svg
verified: true
tags: [desktop, vnc, ai]
---
# Portable Desktop
Install [portabledesktop](https://github.com/coder/portabledesktop) for lightweight Linux desktop sessions over VNC. The binary is stored in the agent's script data directory and is automatically available on PATH via `CODER_SCRIPT_BIN_DIR`.
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
}
```
## Examples
### Custom download URL with checksum verification
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
url = "https://example.com/portabledesktop-linux-x64"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
```
### Additionally copy to a system path
Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory:
```tf
module "portabledesktop" {
source = "registry.coder.com/coder/portabledesktop/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
install_dir = "/usr/local/bin"
}
```
@@ -0,0 +1,242 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "~test";
interface TestFixture {
state: TerraformState;
server: ReturnType<typeof Bun.serve>;
[Symbol.asyncDispose](): Promise<void>;
}
interface ContainerHandle {
id: string;
[Symbol.asyncDispose](): Promise<void>;
}
async function setupContainer(image: string): Promise<ContainerHandle> {
const id = await runContainer(image);
return {
id,
[Symbol.asyncDispose]: async () => {
await removeContainer(id);
},
};
}
const ENV_PREFIX =
'export CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data && export CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin && mkdir -p "$CODER_SCRIPT_DATA_DIR" "$CODER_SCRIPT_BIN_DIR" && ';
async function setupFakeBinaryServer(
dir: string,
extraVars?: Record<string, string>,
): Promise<TestFixture> {
const fakeBinary = "#!/bin/sh\necho portabledesktop";
const server = Bun.serve({
port: 0,
fetch() {
return new Response(fakeBinary);
},
});
const state = await runTerraformApply(dir, {
agent_id: "foo",
url: `http://localhost:${server.port}/portabledesktop`,
...extraVars,
});
return {
state,
server,
[Symbol.asyncDispose]: async () => {
server.stop(true);
},
};
}
describe("portabledesktop", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("installs portabledesktop successfully", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("portabledesktop installed successfully");
// Check binary exists at CODER_SCRIPT_DATA_DIR.
const checkBinary = await execContainer(container.id, [
"test",
"-x",
"/tmp/coder-script-data/portabledesktop",
]);
expect(checkBinary.exitCode).toBe(0);
// Check symlink exists at CODER_SCRIPT_BIN_DIR.
const checkSymlink = await execContainer(container.id, [
"test",
"-L",
"/tmp/coder-script-data/bin/portabledesktop",
]);
expect(checkSymlink.exitCode).toBe(0);
}, 30000);
it("verifies checksum when sha256 is provided", async () => {
const fakeBinary = "#!/bin/sh\necho portabledesktop";
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(fakeBinary);
const sha256 = hasher.digest("hex");
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
sha256,
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("Checksum verified successfully");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
it("fails when sha256 does not match", async () => {
const wrongSha256 =
"0000000000000000000000000000000000000000000000000000000000000000";
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
sha256: wrongSha256,
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(1);
expect(resp.stdout).toContain("Checksum mismatch");
}, 30000);
it("skips checksum verification when sha256 is not set", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).not.toContain("Checksum verified");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
it("falls back to sudo when install_dir is not writable", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
install_dir: "/usr/local/bin",
});
await using container = await setupContainer("alpine/curl");
await execContainer(container.id, [
"sh",
"-c",
"apk add sudo && " +
"adduser -D testuser && " +
"echo 'testuser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && " +
"mkdir -p /usr/local/bin",
]);
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(
container.id,
["sh", "-c", ENV_PREFIX + script],
["--user", "testuser"],
);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("via sudo");
expect(resp.stdout).toContain("portabledesktop installed successfully");
// Verify the binary was copied to the install_dir.
const check = await execContainer(container.id, [
"test",
"-x",
"/usr/local/bin/portabledesktop",
]);
expect(check.exitCode).toBe(0);
}, 30000);
it("creates install_dir if it does not exist", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
install_dir: "/opt/custom/bin",
});
await using container = await setupContainer("alpine/curl");
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("portabledesktop installed successfully");
const check = await execContainer(container.id, [
"test",
"-x",
"/opt/custom/bin/portabledesktop",
]);
expect(check.exitCode).toBe(0);
}, 30000);
it("falls back to wget when curl is not available", async () => {
await using fixture = await setupFakeBinaryServer(import.meta.dir);
await using container = await setupContainer("alpine");
// Install wget but ensure curl is not present.
await execContainer(container.id, [
"sh",
"-c",
"apk add wget && ! command -v curl",
]);
const script = findResourceInstance(fixture.state, "coder_script").script;
const resp = await execContainer(container.id, [
"sh",
"-c",
ENV_PREFIX + script,
]);
expect(resp.exitCode).toBe(0);
expect(resp.stdout).toContain("via wget");
expect(resp.stdout).toContain("portabledesktop installed successfully");
}, 30000);
});
@@ -0,0 +1,65 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "install_dir" {
type = string
description = "Optional directory to copy the binary into (e.g. /usr/local/bin). The binary is always stored in the agent's script data directory and available on PATH via CODER_SCRIPT_BIN_DIR."
default = null
}
variable "url" {
type = string
description = "Custom download URL. Overrides the default GitHub latest release URL when set."
default = null
}
variable "sha256" {
type = string
description = "SHA256 checksum. When set, the downloaded binary is verified against it."
default = null
}
locals {
default_amd64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-x64"
default_arm64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-arm64"
using_custom_url = var.url != null
amd64_url = local.using_custom_url ? var.url : local.default_amd64_url
arm64_url = local.using_custom_url ? var.url : local.default_arm64_url
# Empty string signals "skip verification" to the shell script.
sha256 = var.sha256 != null ? var.sha256 : ""
install_dir = var.install_dir != null ? var.install_dir : ""
}
resource "coder_script" "portabledesktop" {
agent_id = var.agent_id
display_name = "Portable Desktop"
icon = "/icon/desktop.svg"
script = <<-EOT
#!/bin/sh
set -eu
echo -n '${base64encode(file("${path.module}/run.sh"))}' | base64 -d > /tmp/portabledesktop-install.sh
chmod +x /tmp/portabledesktop-install.sh
ARG_AMD64_URL="$(echo -n '${base64encode(local.amd64_url)}' | base64 -d)" \
ARG_ARM64_URL="$(echo -n '${base64encode(local.arm64_url)}' | base64 -d)" \
ARG_SHA256="$(echo -n '${base64encode(local.sha256)}' | base64 -d)" \
ARG_INSTALL_DIR="$(echo -n '${base64encode(local.install_dir)}' | base64 -d)" \
/tmp/portabledesktop-install.sh
EOT
run_on_start = true
}
@@ -0,0 +1,36 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
run "plan_with_custom_install_dir" {
command = plan
variables {
agent_id = "example-agent-id"
install_dir = "/opt/bin"
}
assert {
condition = resource.coder_script.portabledesktop.display_name == "Portable Desktop"
error_message = "Expected coder_script resource to have correct display name"
}
}
run "plan_with_custom_url" {
command = plan
variables {
agent_id = "example-agent-id"
url = "https://example.com/custom-portabledesktop"
sha256 = "abc123"
}
assert {
condition = resource.coder_script.portabledesktop.run_on_start == true
error_message = "Expected coder_script to run on start"
}
}
@@ -0,0 +1,132 @@
#!/usr/bin/env sh
# shellcheck disable=SC2292
# SC2292: We use [ ] instead of [[ ]] for POSIX sh compatibility.
set -eu
error() {
printf "ERROR: %s\n" "$@"
exit 1
}
# Check if portabledesktop is already in PATH.
if command -v portabledesktop > /dev/null 2>&1; then
printf "portabledesktop is already installed and in PATH.\n"
exit 0
fi
# Determine the storage path.
STORAGE_DIR="${CODER_SCRIPT_DATA_DIR}"
BINARY_PATH="${STORAGE_DIR}/portabledesktop"
mkdir -p "${STORAGE_DIR}"
# If the binary already exists and is executable, skip download.
if [ -x "${BINARY_PATH}" ]; then
printf "portabledesktop is already installed at %s, skipping download.\n" "${BINARY_PATH}"
else
# Detect architecture and select the appropriate download URL.
ARCH=$(uname -m)
case "${ARCH}" in
x86_64)
URL="${ARG_AMD64_URL}"
;;
aarch64)
URL="${ARG_ARM64_URL}"
;;
*)
error "Unsupported architecture: ${ARCH}"
;;
esac
# Select download tool.
if command -v curl > /dev/null 2>&1; then
DOWNLOAD_CMD="curl"
elif command -v wget > /dev/null 2>&1; then
DOWNLOAD_CMD="wget"
else
error "No download tool available (curl or wget required)."
fi
# Download with retry loop (3 attempts, 1s sleep between).
TMPFILE=$(mktemp)
MAX_ATTEMPTS=3
DOWNLOAD_SUCCESS=false
ATTEMPT=1
while [ "${ATTEMPT}" -le "${MAX_ATTEMPTS}" ]; do
printf "Downloading portabledesktop (attempt %s/%s) via %s...\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" "${DOWNLOAD_CMD}"
DOWNLOAD_OK=false
if [ "${DOWNLOAD_CMD}" = "curl" ]; then
curl -fsSL "${URL}" -o "${TMPFILE}" && DOWNLOAD_OK=true
else
wget -qO "${TMPFILE}" "${URL}" && DOWNLOAD_OK=true
fi
if [ "${DOWNLOAD_OK}" = "true" ]; then
# Verify checksum when ARG_SHA256 is non-empty.
if [ -n "${ARG_SHA256}" ]; then
CHECKSUM_MATCH=false
if command -v sha256sum > /dev/null 2>&1; then
echo "${ARG_SHA256} ${TMPFILE}" | sha256sum -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true
elif command -v shasum > /dev/null 2>&1; then
echo "${ARG_SHA256} ${TMPFILE}" | shasum -a 256 -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true
else
rm -f "${TMPFILE}"
error "No SHA256 tool available (sha256sum or shasum required)."
fi
if [ "${CHECKSUM_MATCH}" != "true" ]; then
printf "WARNING: Checksum mismatch (attempt %s/%s): expected %s\n" \
"${ATTEMPT}" "${MAX_ATTEMPTS}" "${ARG_SHA256}"
rm -f "${TMPFILE}"
if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then
sleep 1
fi
ATTEMPT=$((ATTEMPT + 1))
continue
fi
printf "Checksum verified successfully.\n"
fi
DOWNLOAD_SUCCESS=true
break
else
printf "WARNING: Download failed (attempt %s/%s).\n" "${ATTEMPT}" "${MAX_ATTEMPTS}"
if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then
sleep 1
fi
fi
ATTEMPT=$((ATTEMPT + 1))
done
if [ "${DOWNLOAD_SUCCESS}" != "true" ]; then
rm -f "${TMPFILE}"
error "Failed to download portabledesktop after ${MAX_ATTEMPTS} attempts."
fi
# Make the binary executable and move to storage path.
chmod 755 "${TMPFILE}"
mv "${TMPFILE}" "${BINARY_PATH}"
fi
# Symlink into CODER_SCRIPT_BIN_DIR for PATH access.
if [ -n "${CODER_SCRIPT_BIN_DIR}" ] && [ ! -e "${CODER_SCRIPT_BIN_DIR}/portabledesktop" ]; then
ln -s "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${CODER_SCRIPT_BIN_DIR}/portabledesktop"
fi
# If ARG_INSTALL_DIR is set, copy the binary there with sudo fallback.
if [ -n "${ARG_INSTALL_DIR}" ]; then
if [ ! -d "${ARG_INSTALL_DIR}" ]; then
mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || sudo mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || true
fi
if cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then
printf "Copied portabledesktop to %s.\n" "${ARG_INSTALL_DIR}/portabledesktop"
elif sudo cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then
printf "Copied portabledesktop to %s (via sudo).\n" "${ARG_INSTALL_DIR}/portabledesktop"
else
error "Failed to copy portabledesktop to ${ARG_INSTALL_DIR}/portabledesktop."
fi
fi
printf "portabledesktop installed successfully.\n"
+11 -8
View File
@@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -30,7 +30,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -44,22 +44,22 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
}
```
### Pre-configure Settings
### Pre-configure Machine Settings
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -69,6 +69,9 @@ module "vscode-web" {
}
```
> [!WARNING]
> Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
### Pin a specific VS Code Web version
By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
@@ -77,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
@@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "1.5.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
}
+283 -27
View File
@@ -1,42 +1,298 @@
import { describe, expect, it } from "bun:test";
import { runTerraformApply, runTerraformInit } from "~test";
import {
describe,
expect,
it,
beforeAll,
afterEach,
setDefaultTimeout,
} from "bun:test";
import {
runTerraformApply,
runTerraformInit,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
} from "~test";
// Set timeout to 2 minutes for tests that install packages
setDefaultTimeout(2 * 60 * 1000);
let cleanupContainers: string[] = [];
afterEach(async () => {
for (const id of cleanupContainers) {
try {
await removeContainer(id);
} catch {
// Ignore cleanup errors
}
}
cleanupContainers = [];
});
describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir);
it("accept_license should be set to true", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "false",
});
};
expect(t).toThrow("Invalid value for variable");
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
it("use_cached and offline can not be used together", () => {
const t = async () => {
it("accept_license should be set to true", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
use_cached: "true",
offline: "true",
accept_license: false,
});
};
expect(t).toThrow("Offline and Use Cached can not be used together");
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
});
it("offline and extensions can not be used together", () => {
const t = async () => {
it("use_cached and offline can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
offline: "true",
extensions: '["1", "2"]',
accept_license: true,
use_cached: true,
offline: true,
});
};
expect(t).toThrow("Offline mode does not allow extensions to be installed");
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline and Use Cached can not be used together",
);
}
});
// More tests depend on shebang refactors
it("offline and extensions can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
extensions: '["ms-python.python"]',
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline mode does not allow extensions to be installed",
);
}
});
it("creates settings file with correct content", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"editor.fontSize": 14}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code-server CLI that the script expects
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
const script = findResourceInstance(state, "coder_script");
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
// Check that settings file was created
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("14");
});
it("merges settings with existing settings file", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code-server CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
// Check that settings were merged (both existing and new should be present)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
it("merges settings using python3 fallback when jq unavailable", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install python3 (ubuntu:22.04 doesn't have it by default)
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"python3",
]);
// Create mock code-server CLI (no jq installed)
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
// Check that settings were merged using python3 fallback
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
it("preserves existing settings when neither jq nor python3 available", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
settings: '{"new.setting": "new_value"}',
});
// Use ubuntu without installing jq or python3 (neither available by default)
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create mock code-server CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "Mock code-server running"
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code-server`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
// Run script - should warn but not fail
const scriptResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(scriptResult.exitCode).toBe(0);
expect(scriptResult.stdout).toContain("Could not merge settings");
// Existing settings should be preserved (not overwritten)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).not.toContain("new.setting");
expect(settingsResult.stdout).not.toContain("new_value");
});
});
+6 -3
View File
@@ -105,7 +105,7 @@ variable "group" {
variable "settings" {
type = any
description = "A map of settings to apply to VS Code web."
description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup."
default = {}
}
@@ -167,6 +167,10 @@ variable "workspace" {
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
locals {
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
}
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" {
INSTALL_PREFIX : var.install_prefix,
EXTENSIONS : join(",", var.extensions),
TELEMETRY_LEVEL : var.telemetry_level,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
SETTINGS_B64 : local.settings_b64,
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
DISABLE_TRUST : var.disable_trust,
+50 -6
View File
@@ -4,13 +4,54 @@ BOLD='\033[0;1m'
EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
# Merge settings from module with existing settings file
# Uses jq if available, falls back to Python3 for deep merge
merge_settings() {
local new_settings="$1"
local settings_file="$2"
if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then
return 0
fi
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
printf '%s\n' "$new_settings" > "$settings_file"
printf "⚙️ Creating settings file...\n"
return 0
fi
local tmpfile
tmpfile="$(mktemp)"
if command -v jq > /dev/null 2>&1; then
if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "⚙️ Merging settings...\n"
return 0
fi
fi
if command -v python3 > /dev/null 2>&1; then
if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "⚙️ Merging settings...\n"
return 0
fi
fi
rm -f "$tmpfile"
printf "Warning: Could not merge settings (jq or python3 required). Keeping existing settings.\n"
return 0
}
# Set extension directory
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set extension directory
# Set server base path
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
@@ -28,11 +69,14 @@ run_vscode_web() {
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
echo "⚙️ Creating settings file..."
mkdir -p ~/.vscode-server/data/Machine
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
# Apply machine settings (merge with existing if present)
SETTINGS_B64='${SETTINGS_B64}'
if [ -n "$SETTINGS_B64" ]; then
if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
else
printf "Warning: Failed to decode settings. Skipping settings configuration.\n"
fi
fi
# Check if vscode-server is already installed for offline or cached mode
+47 -7
View File
@@ -15,7 +15,7 @@ Automatically installs [Node.js](https://github.com/nodejs/node) via [`nvm`](htt
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/thezoker/nodejs/coder"
version = "1.0.13"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -28,17 +28,55 @@ This installs multiple versions of Node.js:
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/thezoker/nodejs/coder"
version = "1.0.13"
version = "1.1.0"
agent_id = coder_agent.example.id
node_versions = [
"18",
"20",
"node"
]
default_node_version = "1.0.13"
default_node_version = "20"
}
```
## Pre and Post Install Scripts
Use `pre_install_script` and `post_install_script` to run custom scripts before and after Node.js installation.
```tf
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/thezoker/nodejs/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
pre_install_script = "echo 'Setting up prerequisites...'"
post_install_script = "npm install -g yarn pnpm"
}
```
## Cross-Module Dependency Ordering
This module uses `coder exp sync` to coordinate execution ordering with other modules. It exposes the following outputs for use with `coder exp sync want`:
- `install_script_name` — the sync name for the main Node.js installation script
- `pre_install_script_name` — the sync name for the pre-install script
- `post_install_script_name` — the sync name for the post-install script
For example, to ensure another module waits for Node.js to be fully installed:
```tf
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/thezoker/nodejs/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
}
# In another module's coder_script, wait for Node.js installation:
# coder exp sync want my-script ${module.nodejs[0].install_script_name}
```
## Full example
A example with all available options:
@@ -47,15 +85,17 @@ A example with all available options:
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/thezoker/nodejs/coder"
version = "1.0.13"
version = "1.1.0"
agent_id = coder_agent.example.id
nvm_version = "1.0.13"
nvm_version = "v0.39.7"
nvm_install_prefix = "/opt/nvm"
node_versions = [
"16",
"18",
"20",
"node"
]
default_node_version = "1.0.13"
default_node_version = "20"
pre_install_script = "echo 'Pre-install setup'"
post_install_script = "npm install -g typescript"
}
```
+118 -8
View File
@@ -38,15 +38,125 @@ variable "default_node_version" {
default = "node"
}
resource "coder_script" "nodejs" {
agent_id = var.agent_id
display_name = "Node.js:"
script = templatefile("${path.module}/run.sh", {
NVM_VERSION : var.nvm_version,
INSTALL_PREFIX : var.nvm_install_prefix,
NODE_VERSIONS : join(",", var.node_versions),
DEFAULT : var.default_node_version,
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Node.js."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing Node.js."
default = null
}
locals {
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
install_script = templatefile("${path.module}/run.sh", {
NVM_VERSION = var.nvm_version,
INSTALL_PREFIX = var.nvm_install_prefix,
NODE_VERSIONS = join(",", var.node_versions),
DEFAULT = var.default_node_version,
})
encoded_install_script = base64encode(local.install_script)
pre_install_script_name = "nodejs-pre_install_script"
install_script_name = "nodejs-install_script"
post_install_script_name = "nodejs-post_install_script"
module_dir_path = "$HOME/.nodejs-module"
pre_install_path = "${local.module_dir_path}/pre_install.sh"
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
install_path = "${local.module_dir_path}/install.sh"
install_log_path = "${local.module_dir_path}/install.log"
post_install_path = "${local.module_dir_path}/post_install.sh"
post_install_log_path = "${local.module_dir_path}/post_install.log"
}
resource "coder_script" "pre_install_script" {
count = var.pre_install_script == null ? 0 : 1
agent_id = var.agent_id
display_name = "Node.js: Pre-Install"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
coder exp sync start ${local.pre_install_script_name}
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
chmod +x ${local.pre_install_path}
${local.pre_install_path} 2>&1 | tee ${local.pre_install_log_path}
EOT
}
resource "coder_script" "nodejs" {
agent_id = var.agent_id
display_name = "Node.js: Install"
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.install_script_name}' EXIT
%{if var.pre_install_script != null~}
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
%{endif~}
coder exp sync start ${local.install_script_name}
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
chmod +x ${local.install_path}
${local.install_path} 2>&1 | tee ${local.install_log_path}
EOT
run_on_start = true
start_blocks_login = true
}
resource "coder_script" "post_install_script" {
count = var.post_install_script != null ? 1 : 0
agent_id = var.agent_id
display_name = "Node.js: Post-Install"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
mkdir -p ${local.module_dir_path}
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
coder exp sync start ${local.post_install_script_name}
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
chmod +x ${local.post_install_path}
${local.post_install_path} 2>&1 | tee ${local.post_install_log_path}
EOT
}
output "pre_install_script_name" {
description = "The name of the pre-install script for coder exp sync coordination."
value = local.pre_install_script_name
}
output "install_script_name" {
description = "The name of the install script for coder exp sync coordination."
value = local.install_script_name
}
output "post_install_script_name" {
description = "The name of the post-install script for coder exp sync coordination."
value = local.post_install_script_name
}
@@ -0,0 +1,137 @@
run "test_nodejs_basic" {
command = plan
variables {
agent_id = "test-agent-123"
}
assert {
condition = var.agent_id == "test-agent-123"
error_message = "Agent ID variable should be set correctly"
}
assert {
condition = var.nvm_version == "master"
error_message = "nvm_version should default to master"
}
assert {
condition = var.default_node_version == "node"
error_message = "default_node_version should default to node"
}
assert {
condition = var.pre_install_script == null
error_message = "pre_install_script should default to null"
}
assert {
condition = var.post_install_script == null
error_message = "post_install_script should default to null"
}
assert {
condition = output.install_script_name == "nodejs-install_script"
error_message = "install_script_name output should be set"
}
}
run "test_with_scripts" {
command = plan
variables {
agent_id = "test-agent-scripts"
pre_install_script = "echo 'Pre-install script'"
post_install_script = "echo 'Post-install script'"
}
assert {
condition = var.pre_install_script == "echo 'Pre-install script'"
error_message = "Pre-install script should be set correctly"
}
assert {
condition = var.post_install_script == "echo 'Post-install script'"
error_message = "Post-install script should be set correctly"
}
assert {
condition = output.pre_install_script_name == "nodejs-pre_install_script"
error_message = "pre_install_script_name output should be set"
}
assert {
condition = output.post_install_script_name == "nodejs-post_install_script"
error_message = "post_install_script_name output should be set"
}
}
run "test_custom_options" {
command = plan
variables {
agent_id = "test-agent-custom"
nvm_version = "v0.39.7"
nvm_install_prefix = ".custom-nvm"
node_versions = ["18", "20", "node"]
default_node_version = "20"
}
assert {
condition = var.nvm_version == "v0.39.7"
error_message = "nvm_version should be set to v0.39.7"
}
assert {
condition = var.nvm_install_prefix == ".custom-nvm"
error_message = "nvm_install_prefix should be set correctly"
}
assert {
condition = length(var.node_versions) == 3
error_message = "node_versions should have 3 entries"
}
assert {
condition = var.default_node_version == "20"
error_message = "default_node_version should be set to 20"
}
}
run "test_with_pre_install_only" {
command = plan
variables {
agent_id = "test-agent-pre"
pre_install_script = "echo 'pre-install'"
}
assert {
condition = var.pre_install_script != null
error_message = "Pre-install script should be set"
}
assert {
condition = var.post_install_script == null
error_message = "Post-install script should default to null"
}
}
run "test_with_post_install_only" {
command = plan
variables {
agent_id = "test-agent-post"
post_install_script = "echo 'post-install'"
}
assert {
condition = var.pre_install_script == null
error_message = "Pre-install script should default to null"
}
assert {
condition = var.post_install_script != null
error_message = "Post-install script should be set"
}
}