mirror of
https://github.com/coder/registry.git
synced 2026-06-07 15:08:15 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db433e4d34 | |||
| 99f3524160 |
@@ -37,7 +37,7 @@ jobs:
|
||||
all:
|
||||
- '**'
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@7b04f660f4ee4f048d18fd341887cf28dfbedfe2 # v1.46.3
|
||||
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
|
||||
@@ -9,12 +9,11 @@ on:
|
||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
||||
- "release/*/*/v*.*.*"
|
||||
branches: # Templates and skills get released when merged to main
|
||||
branches: # Templates get released when merged to main
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/deploy-registry.yaml"
|
||||
- "registry/**/templates/**"
|
||||
- "registry/**/skills/**"
|
||||
- "registry/**/README.md"
|
||||
- ".icons/**"
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
with:
|
||||
version: v2.1
|
||||
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||
|
||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (blocking, HIGH only)
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
with:
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (SARIF)
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
with:
|
||||
inputs: |
|
||||
.github/workflows
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="7" x="14" y="3" rx="1"/>
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="7" x="3" y="3" rx="1"/>
|
||||
<rect width="9" height="7" x="3" y="14" rx="1"/>
|
||||
<rect width="5" height="7" x="16" y="14" rx="1"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 336 B |
@@ -1,339 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used
|
||||
// in the skills README sources frontmatter. Owners and repo names allow
|
||||
// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus
|
||||
// forward slashes for paths like refs/heads/main.
|
||||
var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`)
|
||||
|
||||
// skillsIconPrefix is the relative path prefix from a skills README to the
|
||||
// repo-level .icons directory. The skills README lives at depth 3
|
||||
// (registry/<namespace>/skills/README.md), so the prefix is three levels up.
|
||||
// This is distinct from modules and templates, which live at depth 4 and use
|
||||
// "../../../../.icons/".
|
||||
const skillsIconPrefix = "../../../.icons/"
|
||||
|
||||
// skillOverride holds per-skill presentation metadata defined in the
|
||||
// registry README. All fields are optional.
|
||||
type skillOverride struct {
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Description string `yaml:"description"`
|
||||
Icon string `yaml:"icon"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// skillSource is one entry in the sources list, describing a single source
|
||||
// repo and optional per-skill overrides.
|
||||
type skillSource struct {
|
||||
Repo string `yaml:"repo"`
|
||||
Skills map[string]skillOverride `yaml:"skills"`
|
||||
}
|
||||
|
||||
// coderSkillsFrontmatter is the YAML frontmatter schema for
|
||||
// registry/<namespace>/skills/README.md.
|
||||
type coderSkillsFrontmatter struct {
|
||||
Icon string `yaml:"icon"`
|
||||
Sources []skillSource `yaml:"sources"`
|
||||
}
|
||||
|
||||
// supportedSkillsTopLevelKeys lists the keys allowed at the root of the
|
||||
// skills README frontmatter. Nested keys under sources are validated
|
||||
// separately because the typed unmarshal handles them.
|
||||
var supportedSkillsTopLevelKeys = []string{"icon", "sources"}
|
||||
|
||||
// coderSkillsReadme represents a parsed skills README file.
|
||||
type coderSkillsReadme struct {
|
||||
filePath string
|
||||
body string
|
||||
frontmatter coderSkillsFrontmatter
|
||||
}
|
||||
|
||||
// separateSkillsFrontmatter is like separateFrontmatter but preserves
|
||||
// indentation in the frontmatter block. The skills README uses nested YAML
|
||||
// (per-skill metadata under each source), which the indentation-trimming
|
||||
// behavior of the shared separateFrontmatter helper destroys.
|
||||
func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) {
|
||||
if readmeText == "" {
|
||||
return "", "", xerrors.New("README is empty")
|
||||
}
|
||||
|
||||
const fence = "---"
|
||||
var fmBuilder strings.Builder
|
||||
var bodyBuilder strings.Builder
|
||||
fenceCount := 0
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
|
||||
for lineScanner.Scan() {
|
||||
nextLine := lineScanner.Text()
|
||||
if fenceCount < 2 && strings.TrimSpace(nextLine) == fence {
|
||||
fenceCount++
|
||||
continue
|
||||
}
|
||||
if fenceCount == 0 {
|
||||
break
|
||||
}
|
||||
if fenceCount >= 2 {
|
||||
bodyBuilder.WriteString(nextLine)
|
||||
bodyBuilder.WriteString("\n")
|
||||
} else {
|
||||
fmBuilder.WriteString(nextLine)
|
||||
fmBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if fenceCount < 2 {
|
||||
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
|
||||
}
|
||||
if strings.TrimSpace(fmBuilder.String()) == "" {
|
||||
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
|
||||
}
|
||||
|
||||
return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil
|
||||
}
|
||||
|
||||
// isPermittedSkillsIconURL validates that an icon URL references the
|
||||
// repo-level .icons directory using the 3-deep prefix appropriate for
|
||||
// skills READMEs, and that the file exists on disk.
|
||||
func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error {
|
||||
if !strings.HasPrefix(checkURL, skillsIconPrefix) {
|
||||
return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix)
|
||||
}
|
||||
|
||||
readmeDir := path.Dir(readmeFilePath)
|
||||
resolvedPath := path.Join(readmeDir, checkURL)
|
||||
|
||||
if _, err := os.Stat(resolvedPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
|
||||
}
|
||||
return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSkillsIconURL(iconURL string, filePath string) []error {
|
||||
if iconURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
|
||||
errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
|
||||
return errs
|
||||
}
|
||||
|
||||
if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter
|
||||
// as a YAML map and verifies that every top-level key is in the supported
|
||||
// set. This catches typos like "source:" vs "sources:".
|
||||
func validateSkillsTopLevelKeys(fm string) []error {
|
||||
var rawKeys map[string]any
|
||||
if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil {
|
||||
return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for key := range rawKeys {
|
||||
if !slices.Contains(supportedSkillsTopLevelKeys, key) {
|
||||
errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", ")))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateSkillsSources(sources []skillSource, filePath string) []error {
|
||||
if len(sources) == 0 {
|
||||
return []error{xerrors.New("at least one source repo is required under 'sources'")}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for i, src := range sources {
|
||||
if src.Repo == "" {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i))
|
||||
continue
|
||||
}
|
||||
if !skillsRepoSpecRe.MatchString(src.Repo) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo))
|
||||
}
|
||||
|
||||
for slug, override := range src.Skills {
|
||||
if !validNameRe.MatchString(slug) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug))
|
||||
}
|
||||
|
||||
for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr))
|
||||
}
|
||||
|
||||
// validateCoderResourceTags returns an error for nil tags, which is
|
||||
// fine for modules/templates that require tags but not for skills
|
||||
// where tags are an optional override.
|
||||
if override.Tags != nil {
|
||||
if err := validateCoderResourceTags(override.Tags); err != nil {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error {
|
||||
var errs []error
|
||||
|
||||
for _, err := range validateSkillsIconURL(fm.Icon, filePath) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
for _, err := range validateSkillsSources(fm.Sources, filePath) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) {
|
||||
fm, body, err := separateSkillsFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateSkillsTopLevelKeys(fm)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return coderSkillsReadme{}, remapped
|
||||
}
|
||||
|
||||
yml := coderSkillsFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return coderSkillsReadme{
|
||||
filePath: rm.filePath,
|
||||
body: body,
|
||||
frontmatter: yml,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) {
|
||||
var parsed []coderSkillsReadme
|
||||
var parsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, errs := parseCoderSkillsReadme(rm)
|
||||
if len(errs) != 0 {
|
||||
parsingErrs = append(parsingErrs, errs...)
|
||||
continue
|
||||
}
|
||||
parsed = append(parsed, p)
|
||||
}
|
||||
if len(parsingErrs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: parsingErrs,
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error {
|
||||
var validationErrs []error
|
||||
for _, rm := range readmes {
|
||||
errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter)
|
||||
if len(errs) > 0 {
|
||||
validationErrs = append(validationErrs, errs...)
|
||||
}
|
||||
}
|
||||
if len(validationErrs) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: validationErrs,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateSkillsReadmeFiles walks registry/<namespace>/skills/README.md
|
||||
// entries, skipping namespaces that do not have a skills directory.
|
||||
func aggregateSkillsReadmeFiles() ([]readme, error) {
|
||||
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
for _, nDir := range namespaceDirs {
|
||||
if !nDir.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md")
|
||||
rmBytes, err := os.ReadFile(skillsReadmePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
allReadmeFiles = append(allReadmeFiles, readme{
|
||||
filePath: skillsReadmePath,
|
||||
rawText: string(rmBytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseFile,
|
||||
errors: errs,
|
||||
}
|
||||
}
|
||||
return allReadmeFiles, nil
|
||||
}
|
||||
|
||||
func validateAllCoderSkills() error {
|
||||
allReadmeFiles, err := aggregateSkillsReadmeFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
|
||||
if len(allReadmeFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
|
||||
return nil
|
||||
}
|
||||
@@ -39,10 +39,6 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderSkills()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
display_name: Claude Code self-hosted runner
|
||||
description: Run Anthropic's Claude Code self-hosted runner as a long-lived process inside a Coder workspace, with per-workspace scoped self-eviction so the prebuild reconciler keeps the pool warm.
|
||||
icon: ../../../../.icons/claude.svg
|
||||
verified: false
|
||||
tags: [ai, claude, claude-code, anthropic, runner]
|
||||
---
|
||||
|
||||
# Claude Code self-hosted runner
|
||||
|
||||
Drops Anthropic's [Claude Code self-hosted runner](https://docs.anthropic.com/en/docs/claude-code/self-hosted-runners) into any Coder template that has a `coder_agent` and a workspace image with the runner binary installed (`/usr/local/bin/claude self-hosted-runner` by default).
|
||||
|
||||
The module owns the runner script (writes a per-session wrapper that forces `--permission-mode bypassPermissions`, then spawns a detached supervisor that runs the runner in the foreground and POSTs a delete build to self-evict on drain), the agent environment variables it needs, an optional bot-git askpass setup, and a host Docker socket gid fixup. Agent metadata items (lock status, active sessions, runner ID, last poll) are emitted via the `agent_metadata` output for the parent to splat into a `dynamic "metadata"` block.
|
||||
|
||||
The parent template still owns the `coder_agent` itself, the per-workspace scope-restricted self-evict token (minted via the `Mastercard/restapi` provider against an admin bootstrap token), the prebuild preset, and the infra block (`docker_container`, `kubernetes_pod`, etc.).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This module is part of the [Claude Code self-hosted runners on Coder](https://coder.com/docs/ai-coder/claude-code-self-hosted-runners) recipe, which currently targets Anthropic's EAP build of the runner. Both the runner binary and the wire contract are still evolving; expect API drift until Anthropic ships GA.
|
||||
|
||||
## Usage
|
||||
|
||||
```tf
|
||||
module "claude_self_hosted_runner" {
|
||||
source = "registry.coder.com/coder-labs/claude-self-hosted-runner/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
workspace_id = data.coder_workspace.me.id
|
||||
pool_secret = var.pool_secret
|
||||
self_evict_token = jsondecode(restapi_object.self_evict_token.api_response).key
|
||||
git_bot_token = var.git_bot_token
|
||||
capacity = tonumber(data.coder_parameter.capacity.value)
|
||||
}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
# ... arch, os, dir, startup_script_behavior, etc.
|
||||
|
||||
# Static metadata blocks coexist with the dynamic block below;
|
||||
# Terraform concatenates them on the same coder_agent.
|
||||
metadata {
|
||||
display_name = "CPU"
|
||||
key = "cpu"
|
||||
script = "top -bn1 | awk '/Cpu/ {print $2 \"%\"}'"
|
||||
interval = 10
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
dynamic "metadata" {
|
||||
for_each = module.claude_self_hosted_runner.agent_metadata
|
||||
content {
|
||||
display_name = metadata.value.display_name
|
||||
key = metadata.value.key
|
||||
interval = metadata.value.interval
|
||||
timeout = metadata.value.timeout
|
||||
script = metadata.value.script
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What the module does
|
||||
|
||||
- Writes `$HOME/.claude/wrapper.sh` at agent start. The wrapper appends `--permission-mode bypassPermissions` after `"$@"` so unattended sessions never stall on a tool-approval prompt; Claude Code's flag parser is last-occurrence-wins, so this overrides the server-supplied permission mode.
|
||||
- Sets up the runner's required environment (`CLAUDE_POOL_SECRET`, `CLAUDE_CAPACITY`, `GIT_BOT_TOKEN`, `CODER_SELF_TOKEN`, `CODER_WORKSPACE_ID`) via `coder_env` resources on the agent.
|
||||
- Spawns a `setsid nohup` supervisor that runs the runner in the foreground. When the runner exits on drain, the supervisor POSTs `/api/v2/workspaces/{id}/builds` with `{"transition":"delete"}` to self-evict, so Coder's prebuild reconciler can queue a replacement.
|
||||
- Wires up `GIT_ASKPASS` if `git_bot_token` is supplied so the runner's child claude can `git push` without baking credentials into the image.
|
||||
- If the parent template mounts the host Docker socket at `/var/run/docker.sock` and the gid does not match the in-container `docker` group, chgrps the socket so the workspace user can use it without sudo.
|
||||
|
||||
## Self-eviction security model
|
||||
|
||||
The `self_evict_token` input is minted by the parent template via the `Mastercard/restapi` provider at template build time, against an admin bootstrap token that lives in Terraform state and is never injected into the workspace. The minted token is scoped to `workspace:delete + workspace:read + template:read + user:read` and allow-listed to this single workspace's UUID. A leaked copy can do exactly one thing: delete this one workspace. No read of peer prebuilds, no SSH, no external auth, no git creds.
|
||||
|
||||
The supervisor uses raw `curl` against `/api/v2/workspaces/{id}/builds`, not the `coder delete` CLI. The CLI fetches workspace resources first, which fails against the scoped token whose allow-list intersection excludes peer workspaces.
|
||||
@@ -0,0 +1,185 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "workspace_id" {
|
||||
type = string
|
||||
description = "data.coder_workspace.me.id from the parent template. Used by the supervisor to self-evict via the workspace builds endpoint."
|
||||
}
|
||||
|
||||
variable "pool_secret" {
|
||||
type = string
|
||||
description = "Claude Code self-hosted runner pool secret (from claude.ai)."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "self_evict_token" {
|
||||
type = string
|
||||
description = "Per-workspace, scope-restricted Coder API token. Scope = workspace:delete + workspace:read + template:read + user:read, allow_list = this workspace's UUID. A leaked copy can only delete this one workspace. The parent template mints it via the Mastercard/restapi provider at build time."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "git_bot_token" {
|
||||
type = string
|
||||
description = "Optional git PAT for the bot identity. Wired through GIT_ASKPASS so the runner's child claude can push without baking credentials into the image."
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "capacity" {
|
||||
type = number
|
||||
description = "Maximum sessions the runner serves at once. The runner locks to one Anthropic user; this caps parallelism within that user's queue."
|
||||
default = 4
|
||||
validation {
|
||||
condition = var.capacity >= 1 && var.capacity <= 16
|
||||
error_message = "capacity must be between 1 and 16."
|
||||
}
|
||||
}
|
||||
|
||||
variable "runner_binary_path" {
|
||||
type = string
|
||||
description = "Path to the `claude self-hosted-runner` binary inside the workspace."
|
||||
default = "/usr/local/bin/claude"
|
||||
}
|
||||
|
||||
variable "claude_binary_path" {
|
||||
type = string
|
||||
description = "Path to the Claude Code binary the wrapper execs for each session."
|
||||
default = "/opt/claude/claude"
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "Order of the runner script in the agent UI."
|
||||
default = null
|
||||
}
|
||||
|
||||
resource "coder_env" "pool_secret" {
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_POOL_SECRET"
|
||||
value = var.pool_secret
|
||||
}
|
||||
|
||||
resource "coder_env" "capacity" {
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_CAPACITY"
|
||||
value = tostring(var.capacity)
|
||||
}
|
||||
|
||||
resource "coder_env" "git_bot_token" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_BOT_TOKEN"
|
||||
value = var.git_bot_token
|
||||
}
|
||||
|
||||
resource "coder_env" "self_token" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_SELF_TOKEN"
|
||||
value = var.self_evict_token
|
||||
}
|
||||
|
||||
resource "coder_env" "workspace_id" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_WORKSPACE_ID"
|
||||
value = var.workspace_id
|
||||
}
|
||||
|
||||
resource "coder_script" "claude_runner" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Claude self-hosted runner"
|
||||
icon = "/icon/code.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = templatefile("${path.module}/scripts/run.sh", {
|
||||
CLAUDE_BINARY_PATH = var.claude_binary_path
|
||||
RUNNER_BINARY_PATH = var.runner_binary_path
|
||||
})
|
||||
}
|
||||
|
||||
# Agent metadata items. The parent splats this list into a
|
||||
# `dynamic "metadata"` block on its own `coder_agent` because nested
|
||||
# blocks cannot be injected from a module. Scraped from the runner's
|
||||
# local /healthz and /metrics endpoints; this is the only window a
|
||||
# Coder admin has into who the Anthropic pool has bound this workspace
|
||||
# to (the runner does not expose the locked user's email over its
|
||||
# local endpoints; that lives in claude.ai > Self-hosted runner pools).
|
||||
output "agent_metadata" {
|
||||
description = "List of agent metadata items the parent template should splat into a `dynamic \"metadata\"` block on its coder_agent."
|
||||
value = [
|
||||
{
|
||||
display_name = "Lock status"
|
||||
key = "0_lock_status"
|
||||
interval = 10
|
||||
timeout = 5
|
||||
# The runner does not expose its locked state via /metrics or
|
||||
# /healthz in the current BYOC build, so we infer it from
|
||||
# active_sessions and latch a sticky flag on disk: once a
|
||||
# session has landed, the runner is locked to that Anthropic
|
||||
# user for its entire lifetime per Anthropic's spec, even when
|
||||
# the active count drops back to zero between sessions.
|
||||
script = <<-EOT
|
||||
flag="$HOME/.claude/locked"
|
||||
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.active_sessions // 0')
|
||||
if [ "$${active:-0}" -gt 0 ] && [ ! -f "$flag" ]; then
|
||||
touch "$flag" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$flag" ]; then
|
||||
printf 'locked'
|
||||
else
|
||||
printf 'unlocked'
|
||||
fi
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Active sessions"
|
||||
key = "1_active_sessions"
|
||||
interval = 5
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.active_sessions // empty')
|
||||
if [ -z "$active" ]; then echo '?'; exit 0; fi
|
||||
printf '%s / %s' "$active" "$${CLAUDE_CAPACITY:-1}"
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Runner ID"
|
||||
key = "2_runner_id"
|
||||
interval = 30
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.runner_id // "(starting)"'
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Last Anthropic poll"
|
||||
key = "3_last_poll"
|
||||
interval = 15
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
age=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.last_poll_age_ms // empty')
|
||||
if [ -z "$age" ]; then echo '?'; exit 0; fi
|
||||
if [ "$age" -lt 30000 ]; then
|
||||
printf 'ok (%sms ago)' "$age"
|
||||
else
|
||||
printf 'stale (%ss ago)' $((age/1000))
|
||||
fi
|
||||
EOT
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
run "plan_with_required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.pool_secret.value) > 0
|
||||
error_message = "pool_secret env should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.capacity.value == "4"
|
||||
error_message = "default capacity should be 4"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_script.claude_runner.display_name == "Claude self-hosted runner"
|
||||
error_message = "expected the runner coder_script display_name"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_capacity_and_binary_paths" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 8
|
||||
claude_binary_path = "/custom/claude"
|
||||
runner_binary_path = "/custom/runner"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.capacity.value == "8"
|
||||
error_message = "capacity input should flow into CLAUDE_CAPACITY env"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/claude")
|
||||
error_message = "claude_binary_path should appear in the rendered script"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/runner")
|
||||
error_message = "runner_binary_path should appear in the rendered script"
|
||||
}
|
||||
}
|
||||
|
||||
run "git_bot_token_optional" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.git_bot_token.value == ""
|
||||
error_message = "git_bot_token should default to empty string"
|
||||
}
|
||||
}
|
||||
|
||||
run "capacity_validation_rejects_zero" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 0
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.capacity,
|
||||
]
|
||||
}
|
||||
|
||||
run "capacity_validation_rejects_high" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 17
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.capacity,
|
||||
]
|
||||
}
|
||||
|
||||
run "agent_metadata_output_has_four_items" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.agent_metadata) == 4
|
||||
error_message = "agent_metadata should expose four scraping items"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.agent_metadata[0].key == "0_lock_status"
|
||||
error_message = "first metadata item should be lock_status"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wires up everything the Claude Code self-hosted runner needs at agent
|
||||
# start, then spawns a detached supervisor that keeps the runner alive
|
||||
# and self-evicts on drain.
|
||||
#
|
||||
# Runtime env (set by coder_env in main.tf):
|
||||
# CLAUDE_POOL_SECRET Anthropic pool secret (mandatory).
|
||||
# CLAUDE_CAPACITY Max parallel sessions per runner (default 1).
|
||||
# GIT_BOT_TOKEN Optional bot PAT for GIT_ASKPASS.
|
||||
# CODER_SELF_TOKEN Per-workspace scope-restricted Coder API token.
|
||||
# CODER_WORKSPACE_ID This workspace's UUID, used by self-eviction.
|
||||
# CODER_AGENT_URL Set by the Coder agent itself.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CLAUDE_BINARY_PATH='${CLAUDE_BINARY_PATH}'
|
||||
RUNNER_BINARY_PATH='${RUNNER_BINARY_PATH}'
|
||||
|
||||
if [ -z "$${CLAUDE_POOL_SECRET:-}" ]; then
|
||||
echo "CLAUDE_POOL_SECRET is empty. Set the pool_secret input on the module."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 0700 "$HOME/.claude"
|
||||
|
||||
# --- Bot git askpass ----------------------------------------------------
|
||||
if [ -n "$${GIT_BOT_TOKEN:-}" ]; then
|
||||
install -d -m 0700 "$HOME/.git-creds"
|
||||
cat > "$HOME/.git-creds/askpass.sh" << 'ASK'
|
||||
#!/bin/sh
|
||||
printf '%s' "$GIT_BOT_TOKEN"
|
||||
ASK
|
||||
chmod 0500 "$HOME/.git-creds/askpass.sh"
|
||||
git config --global core.askPass "$HOME/.git-creds/askpass.sh"
|
||||
git config --global credential.helper ''
|
||||
fi
|
||||
|
||||
# --- Host Docker socket gid fixup --------------------------------------
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
sock_gid=$(stat -c %g /var/run/docker.sock)
|
||||
docker_gid=$(getent group docker | cut -d: -f3 || true)
|
||||
if [ -n "$${docker_gid:-}" ] && [ "$${sock_gid}" != "$${docker_gid}" ]; then
|
||||
sudo chgrp "$${docker_gid}" /var/run/docker.sock 2> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Pool secret on disk -----------------------------------------------
|
||||
POOL_SECRET_FILE="$HOME/.claude/pool-secret"
|
||||
rm -f "$POOL_SECRET_FILE"
|
||||
umask 077
|
||||
printf '%s' "$${CLAUDE_POOL_SECRET}" > "$POOL_SECRET_FILE"
|
||||
chmod 0400 "$POOL_SECRET_FILE"
|
||||
|
||||
# --- Wrapper script -----------------------------------------------------
|
||||
# Runner execs this once per session, appending its server-computed
|
||||
# flags. Claude Code's flag parser is last-occurrence-wins, so flags
|
||||
# after "$@" win. Force --permission-mode bypassPermissions so
|
||||
# unattended sessions never stall on a tool-approval prompt.
|
||||
WRAPPER="$HOME/.claude/wrapper.sh"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo "exec $${CLAUDE_BINARY_PATH} \"\$@\" --permission-mode bypassPermissions"
|
||||
} > "$WRAPPER"
|
||||
chmod 0755 "$WRAPPER"
|
||||
|
||||
# --- Supervisor --------------------------------------------------------
|
||||
# Runs the runner in the foreground; on runner exit POSTs a delete
|
||||
# build to self-evict. Raw curl, not `coder delete`: the CLI fetches
|
||||
# workspace resources first, which fails with the per-workspace
|
||||
# scoped token whose allow-list excludes peer prebuilds.
|
||||
#
|
||||
# Single-quoted heredoc, so nothing is expanded by the outer shell.
|
||||
# The supervisor reads its env vars (CODER_SELF_TOKEN, CODER_AGENT_URL,
|
||||
# etc.) at runtime, when it's invoked under setsid.
|
||||
SUPERVISOR="$HOME/.claude/supervisor.sh"
|
||||
cat > "$SUPERVISOR" << SUP
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
exec >>"\$HOME/.claude/supervisor.log" 2>&1
|
||||
echo "[supervisor] start \$(date -Is)"
|
||||
|
||||
$${RUNNER_BINARY_PATH} self-hosted-runner \\
|
||||
--pool-secret-file "\$HOME/.claude/pool-secret" \\
|
||||
--capacity "\$${CLAUDE_CAPACITY:-1}" \\
|
||||
--log-file "\$HOME/.claude/runner.log" \\
|
||||
--exec-path "\$HOME/.claude/wrapper.sh"
|
||||
echo "[supervisor] runner exited rc=\$? \$(date -Is)"
|
||||
|
||||
if [ -z "\$${CODER_SELF_TOKEN:-}" ]; then
|
||||
echo "[supervisor] CODER_SELF_TOKEN is empty; skipping self-eviction."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
http_code=\$(curl -s -o /tmp/evict.out -w "%%{http_code}" \\
|
||||
-X POST \\
|
||||
-H "Coder-Session-Token: \$CODER_SELF_TOKEN" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"transition":"delete"}' \\
|
||||
"\$CODER_AGENT_URL/api/v2/workspaces/\$CODER_WORKSPACE_ID/builds")
|
||||
if [ "\$http_code" = "201" ]; then
|
||||
echo "[supervisor] self-eviction queued (HTTP 201)."
|
||||
else
|
||||
echo "[supervisor] self-eviction failed (HTTP \$http_code): \$(head -c 300 /tmp/evict.out)"
|
||||
fi
|
||||
SUP
|
||||
chmod 0700 "$SUPERVISOR"
|
||||
|
||||
# Detach with setsid + nohup. The supervisor reopens stdout/stderr to
|
||||
# its own logfile; redirect all standard fds here to /dev/null so this
|
||||
# script's exit doesn't drag the supervisor with it.
|
||||
setsid nohup "$SUPERVISOR" < /dev/null > /dev/null 2>&1 &
|
||||
disown
|
||||
|
||||
echo "Runner spawned as detached supervisor (pid=$!). See ~/.claude/supervisor.log."
|
||||
@@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ locals {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = local.claude_workdir
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -78,7 +78,7 @@ resource "coder_app" "claude" {
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_ai_gateway = true
|
||||
@@ -95,33 +95,6 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
|
||||
> [!CAUTION]
|
||||
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
|
||||
|
||||
### Enterprise policy via managed settings
|
||||
|
||||
The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
|
||||
managed_settings = {
|
||||
permissions = {
|
||||
defaultMode = "acceptEdits"
|
||||
disableBypassPermissionsMode = "disable"
|
||||
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
|
||||
}
|
||||
env = {
|
||||
DISABLE_TELEMETRY = "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
|
||||
@@ -129,7 +102,7 @@ This example shows version pinning, a pre-installed binary path, a custom model,
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -193,7 +166,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -279,7 +252,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -336,7 +309,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
@@ -377,7 +350,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
version = "5.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
|
||||
@@ -382,13 +382,10 @@ describe("claude-code", async () => {
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.autoUpdaterStatus).toBe("disabled");
|
||||
expect(parsed.hasCompletedOnboarding).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
|
||||
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
|
||||
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
|
||||
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
|
||||
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
|
||||
// not user-writable ~/.claude.json acceptance flags.
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
||||
expect(parsed.autoModeAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("standalone-mode-with-oauth-token", async () => {
|
||||
@@ -416,7 +413,7 @@ describe("claude-code", async () => {
|
||||
);
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.hasCompletedOnboarding).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
|
||||
});
|
||||
|
||||
test("standalone-mode-no-auth", async () => {
|
||||
@@ -439,49 +436,6 @@ describe("claude-code", async () => {
|
||||
expect(resp.stdout.trim()).toBe("ABSENT");
|
||||
});
|
||||
|
||||
test("claude-managed-settings-written", async () => {
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
managed_settings: JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "acceptEdits",
|
||||
disableBypassPermissionsMode: "disable",
|
||||
deny: ["Bash(rm -rf*)"],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const policy = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /etc/claude-code/managed-settings.d/10-coder.json",
|
||||
]);
|
||||
expect(policy.exitCode).toBe(0);
|
||||
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
|
||||
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
|
||||
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Wrote Claude Code managed settings");
|
||||
});
|
||||
|
||||
test("claude-managed-settings-not-set", async () => {
|
||||
const { id, scripts } = await setup();
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
|
||||
]);
|
||||
expect(resp.stdout.trim()).toBe("ABSENT");
|
||||
});
|
||||
|
||||
test("telemetry-otel", async () => {
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
|
||||
@@ -102,12 +102,6 @@ variable "claude_binary_path" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "managed_settings" {
|
||||
type = any
|
||||
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_ai_gateway" {
|
||||
type = bool
|
||||
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
|
||||
@@ -243,7 +237,6 @@ locals {
|
||||
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
|
||||
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
|
||||
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
|
||||
})
|
||||
module_dir_name = ".coder-modules/coder/claude-code"
|
||||
}
|
||||
|
||||
@@ -283,47 +283,3 @@ run "test_workdir_optional" {
|
||||
error_message = "workdir should default to null when omitted"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_managed_settings" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-managed-settings"
|
||||
workdir = "/home/coder/project"
|
||||
managed_settings = {
|
||||
permissions = {
|
||||
defaultMode = "acceptEdits"
|
||||
disableBypassPermissionsMode = "disable"
|
||||
deny = ["Bash(rm -rf*)"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
|
||||
error_message = "managed_settings should accept the permissions object"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
|
||||
error_message = "install script should reference the managed-settings.d drop-in directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
|
||||
error_message = "install script should embed the base64-encoded managed_settings JSON"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_managed_settings_default_null" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-managed-settings-default"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.managed_settings == null
|
||||
error_message = "managed_settings should default to null when omitted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
|
||||
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
|
||||
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
|
||||
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
|
||||
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
|
||||
|
||||
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
|
||||
|
||||
@@ -30,7 +29,6 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
|
||||
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
|
||||
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
|
||||
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
|
||||
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
@@ -146,32 +144,6 @@ function setup_claude_configurations() {
|
||||
|
||||
}
|
||||
|
||||
function write_managed_settings() {
|
||||
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local dropin_dir="/etc/claude-code/managed-settings.d"
|
||||
local target="$${dropin_dir}/10-coder.json"
|
||||
|
||||
if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
|
||||
echo "Warning: managed_settings is not valid JSON, skipping policy write"
|
||||
return
|
||||
fi
|
||||
|
||||
if command_exists sudo; then
|
||||
sudo mkdir -p "$${dropin_dir}"
|
||||
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
|
||||
sudo chmod 0644 "$${target}"
|
||||
else
|
||||
mkdir -p "$${dropin_dir}"
|
||||
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
|
||||
chmod 0644 "$${target}"
|
||||
fi
|
||||
|
||||
echo "Wrote Claude Code managed settings to $${target}"
|
||||
}
|
||||
|
||||
function configure_standalone_mode() {
|
||||
echo "Configuring Claude Code for standalone mode..."
|
||||
|
||||
@@ -186,6 +158,8 @@ function configure_standalone_mode() {
|
||||
echo "Updating existing Claude configuration at $${claude_config}"
|
||||
|
||||
jq '.autoUpdaterStatus = "disabled" |
|
||||
.autoModeAccepted = true |
|
||||
.bypassPermissionsModeAccepted = true |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
.hasCompletedOnboarding = true' \
|
||||
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
|
||||
@@ -194,6 +168,8 @@ function configure_standalone_mode() {
|
||||
cat > "$${claude_config}" << EOF
|
||||
{
|
||||
"autoUpdaterStatus": "disabled",
|
||||
"autoModeAccepted": true,
|
||||
"bypassPermissionsModeAccepted": true,
|
||||
"hasAcknowledgedCostThreshold": true,
|
||||
"hasCompletedOnboarding": true
|
||||
}
|
||||
@@ -213,5 +189,4 @@ EOF
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
write_managed_settings
|
||||
configure_standalone_mode
|
||||
|
||||
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
}
|
||||
@@ -28,7 +28,7 @@ module "git-clone" {
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
base_dir = "~/projects/coder"
|
||||
@@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
}
|
||||
@@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
|
||||
module "git_clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = data.coder_parameter.git_repo.value
|
||||
}
|
||||
@@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.example.com/coder/coder/tree/feat/example"
|
||||
git_providers = {
|
||||
@@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
|
||||
}
|
||||
@@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
|
||||
git_providers = {
|
||||
@@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
branch_name = "feat/example"
|
||||
@@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
folder_name = "coder-dev"
|
||||
@@ -185,32 +185,21 @@ module "git-clone" {
|
||||
}
|
||||
```
|
||||
|
||||
## Extra `git clone` arguments
|
||||
## Git shallow clone
|
||||
|
||||
> [!NOTE]
|
||||
> **Upgrading from v1.x?** The `depth` variable was removed in v2.0.0. Use `extra_args = ["--depth=1"]` instead.
|
||||
> Do not pass `-b` or `--branch` in `extra_args` when `branch_name` is
|
||||
> already set (or extracted from the URL). Git silently accepts the last
|
||||
> `-b` flag, so the two values would conflict.
|
||||
Limit the clone history to speed-up workspace startup by setting `depth`.
|
||||
|
||||
Pass any additional flags through `extra_args` (one element per argument).
|
||||
This lets you enable anything `git clone` supports without the module having
|
||||
to expose it explicitly, for example a shallow clone, submodules, parallel
|
||||
fetches, or partial clones.
|
||||
When `depth` is greater than `0` the module runs `git clone --depth <depth>`.
|
||||
If not defined, the default, `0`, performs a full clone.
|
||||
|
||||
```tf
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
extra_args = [
|
||||
"--depth=1",
|
||||
"--recurse-submodules",
|
||||
"--jobs=8",
|
||||
"--filter=blob:none",
|
||||
]
|
||||
depth = 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -223,7 +212,7 @@ This is useful for preparing the environment or validating prerequisites before
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
pre_clone_script = <<-EOT
|
||||
@@ -246,7 +235,7 @@ This is useful for running initialization tasks like installing dependencies or
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
post_clone_script = <<-EOT
|
||||
@@ -259,7 +248,3 @@ module "git-clone" {
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Logs and scripts for `clone`, `pre_clone`, and `post_clone` are written to `~/.coder-modules/coder/git-clone/<folder_name>/logs/` and `~/.coder-modules/coder/git-clone/<folder_name>/scripts/` respectively.
|
||||
|
||||
@@ -1,48 +1,11 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
runContainer,
|
||||
executeScriptInContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
type scriptOutput,
|
||||
type TerraformState,
|
||||
} from "~test";
|
||||
|
||||
const executeScriptInContainer = async (
|
||||
state: TerraformState,
|
||||
image: string,
|
||||
before?: string,
|
||||
): Promise<scriptOutput> => {
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer(image);
|
||||
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
|
||||
if (before) {
|
||||
await execContainer(id, ["sh", "-c", before]);
|
||||
}
|
||||
const resp = await execContainer(id, ["bash", "-c", instance.script]);
|
||||
return {
|
||||
exitCode: resp.exitCode,
|
||||
stdout: resp.stdout.trim().split("\n"),
|
||||
stderr: resp.stderr.trim().split("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
// Drops a fake `git` onto PATH that prints each argv entry on its own line.
|
||||
// Lets tests prove that arguments (including ones with embedded spaces) reach
|
||||
// `git clone` as single argv tokens, which the echo line cannot show because
|
||||
// it joins with spaces.
|
||||
const installFakeGit = [
|
||||
"cat > /usr/local/bin/git <<'SHIM'",
|
||||
"#!/bin/sh",
|
||||
'for arg in "$@"; do',
|
||||
' printf "argv:%s\\n" "$arg"',
|
||||
"done",
|
||||
"SHIM",
|
||||
"chmod +x /usr/local/bin/git",
|
||||
].join("\n");
|
||||
|
||||
describe("git-clone", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
@@ -67,11 +30,12 @@ describe("git-clone", async () => {
|
||||
url: "fake-url",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.stdout).toContain("Creating directory /root/fake-url...");
|
||||
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
expect(output.exitCode).not.toBe(0);
|
||||
expect(output.stdout.join(" ")).toContain("fatal");
|
||||
expect(output.stdout.join(" ")).toContain("fake-url");
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/fake-url...",
|
||||
"Cloning fake-url to ~/fake-url...",
|
||||
]);
|
||||
expect(output.stderr.join(" ")).toContain("fatal");
|
||||
expect(output.stderr.join(" ")).toContain("fake-url");
|
||||
});
|
||||
|
||||
it("repo_dir should match repo name for https", async () => {
|
||||
@@ -242,12 +206,10 @@ describe("git-clone", async () => {
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs with gitlab clone with switch to feat/branch", async () => {
|
||||
@@ -257,12 +219,10 @@ describe("git-clone", async () => {
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://gitlab.com/mike.brew/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs with github clone with branch_name set to feat/branch", async () => {
|
||||
@@ -280,25 +240,23 @@ describe("git-clone", async () => {
|
||||
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs post-clone script", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
base_dir: "/tmp",
|
||||
post_clone_script: "echo 'Post-clone script executed'",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
|
||||
"sh",
|
||||
"mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
|
||||
);
|
||||
expect(output.stdout).toContain("Running post-clone script...");
|
||||
expect(output.stdout).toContain("Post-clone script executed");
|
||||
@@ -313,133 +271,6 @@ describe("git-clone", async () => {
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.stdout).toContain("Running pre-clone script...");
|
||||
expect(output.stdout).toContain("Pre-clone script executed");
|
||||
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
});
|
||||
|
||||
it("fails when pre-clone script fails", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
pre_clone_script: "echo 'Pre-clone script failed'; exit 42",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(42);
|
||||
expect(output.stdout).toContain("Running pre-clone script...");
|
||||
expect(output.stdout).toContain("Pre-clone script failed");
|
||||
expect(output.stdout).not.toContain(
|
||||
"Cloning fake-url to /root/fake-url...",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults extra_args to empty", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
// With no extra_args the only argv tokens should be clone, url, path.
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
["argv:clone", "argv:fake-url", "argv:/root/fake-url"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes extra_args to git clone", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
extra_args: JSON.stringify([
|
||||
"--recurse-submodules",
|
||||
"--jobs=8",
|
||||
"--config=user.name=Coder User",
|
||||
"-c",
|
||||
"core.sshCommand=ssh -i /tmp/key",
|
||||
]),
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
[
|
||||
"argv:clone",
|
||||
"argv:--recurse-submodules",
|
||||
"argv:--jobs=8",
|
||||
"argv:--config=user.name=Coder User",
|
||||
"argv:-c",
|
||||
"argv:core.sshCommand=ssh -i /tmp/key",
|
||||
"argv:fake-url",
|
||||
"argv:/root/fake-url",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes extra_args alongside branch_name in the correct order", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
branch_name: "feat/branch",
|
||||
extra_args: JSON.stringify([
|
||||
"--recurse-submodules",
|
||||
"--config=user.name=Coder User",
|
||||
]),
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
[
|
||||
"argv:clone",
|
||||
"argv:--recurse-submodules",
|
||||
"argv:--config=user.name=Coder User",
|
||||
"argv:-b",
|
||||
"argv:feat/branch",
|
||||
"argv:fake-url",
|
||||
"argv:/root/fake-url",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes output to logs/clone.log under module directory", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
});
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/git");
|
||||
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
|
||||
await execContainer(id, ["bash", "-c", instance.script]);
|
||||
const log = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /root/.coder-modules/coder/git-clone/*/logs/clone.log",
|
||||
]);
|
||||
expect(log.exitCode).toBe(0);
|
||||
expect(log.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
});
|
||||
|
||||
it("fails when post-clone script fails", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
base_dir: "/tmp",
|
||||
post_clone_script: "echo 'Post-clone script failed'; exit 43",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
|
||||
);
|
||||
expect(output.exitCode).toBe(43);
|
||||
expect(output.stdout).toContain("Running post-clone script...");
|
||||
expect(output.stdout).toContain("Post-clone script failed");
|
||||
expect(output.stdout).toContain("Cloning fake-url to ~/fake-url...");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,10 @@ variable "folder_name" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "extra_args" {
|
||||
description = "Extra arguments to pass to `git clone`, one element per argument (e.g. `[\"--recurse-submodules\", \"--jobs=8\", \"--filter=blob:none\"]`)."
|
||||
type = list(string)
|
||||
default = []
|
||||
variable "depth" {
|
||||
description = "If > 0, perform a shallow clone using this depth."
|
||||
type = number
|
||||
default = 0
|
||||
}
|
||||
|
||||
variable "post_clone_script" {
|
||||
@@ -97,30 +97,6 @@ locals {
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
# Encode the pre_clone_script for passing to the shell script
|
||||
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
|
||||
encoded_extra_args = base64encode(join("\n", var.extra_args))
|
||||
|
||||
# Module directory paths (matches coder-utils convention)
|
||||
# Use folder_name so two git-clone instances in the same template get
|
||||
# separate script and log directories.
|
||||
module_dir = "$HOME/.coder-modules/coder/git-clone/${local.folder_name}"
|
||||
scripts_directory = "${local.module_dir}/scripts"
|
||||
log_directory = "${local.module_dir}/logs"
|
||||
clone_script_path = "${local.scripts_directory}/clone.sh"
|
||||
clone_log_path = "${local.log_directory}/clone.log"
|
||||
pre_clone_log_path = "${local.log_directory}/pre_clone.log"
|
||||
post_clone_log_path = "${local.log_directory}/post_clone.log"
|
||||
|
||||
encoded_clone_script = base64encode(templatefile("${path.module}/run.sh", {
|
||||
CLONE_PATH = local.clone_path,
|
||||
REPO_URL = local.clone_url,
|
||||
BRANCH_NAME = local.branch_name,
|
||||
EXTRA_ARGS = local.encoded_extra_args,
|
||||
POST_CLONE_SCRIPT = local.encoded_post_clone_script,
|
||||
PRE_CLONE_SCRIPT = local.encoded_pre_clone_script,
|
||||
SCRIPTS_DIR = local.scripts_directory,
|
||||
PRE_CLONE_LOG_PATH = local.pre_clone_log_path,
|
||||
POST_CLONE_LOG_PATH = local.post_clone_log_path,
|
||||
}))
|
||||
}
|
||||
|
||||
output "repo_dir" {
|
||||
@@ -154,21 +130,15 @@ output "branch_name" {
|
||||
}
|
||||
|
||||
resource "coder_script" "git_clone" {
|
||||
agent_id = var.agent_id
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p "${local.module_dir}"
|
||||
mkdir -p "${local.scripts_directory}"
|
||||
mkdir -p "${local.log_directory}"
|
||||
|
||||
echo -n '${local.encoded_clone_script}' | base64 -d > "${local.clone_script_path}"
|
||||
chmod +x "${local.clone_script_path}"
|
||||
|
||||
"${local.clone_script_path}" 2>&1 | tee "${local.clone_log_path}"
|
||||
EOT
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
CLONE_PATH = local.clone_path,
|
||||
REPO_URL : local.clone_url,
|
||||
BRANCH_NAME : local.branch_name,
|
||||
DEPTH = var.depth,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
|
||||
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
|
||||
})
|
||||
display_name = "Git Clone"
|
||||
icon = "/icon/git.svg"
|
||||
run_on_start = true
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${REPO_URL}"
|
||||
CLONE_PATH="${CLONE_PATH}"
|
||||
BRANCH_NAME="${BRANCH_NAME}"
|
||||
# Expand home if it's specified!
|
||||
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
|
||||
EXTRA_ARGS="${EXTRA_ARGS}"
|
||||
DEPTH="${DEPTH}"
|
||||
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
|
||||
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
|
||||
SCRIPTS_DIR="${SCRIPTS_DIR}"
|
||||
PRE_CLONE_LOG_PATH="${PRE_CLONE_LOG_PATH}"
|
||||
POST_CLONE_LOG_PATH="${POST_CLONE_LOG_PATH}"
|
||||
|
||||
# Check if the variable is empty...
|
||||
if [ -z "$REPO_URL" ]; then
|
||||
@@ -42,18 +37,11 @@ fi
|
||||
# Run pre-clone script if provided
|
||||
if [ -n "$PRE_CLONE_SCRIPT" ]; then
|
||||
echo "Running pre-clone script..."
|
||||
PRE_CLONE_PATH="$SCRIPTS_DIR/pre_clone.sh"
|
||||
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_PATH"
|
||||
chmod +x "$PRE_CLONE_PATH"
|
||||
"$PRE_CLONE_PATH" 2>&1 | tee "$PRE_CLONE_LOG_PATH"
|
||||
fi
|
||||
|
||||
# Build optional git clone flags
|
||||
extra_args=()
|
||||
if [ -n "$EXTRA_ARGS" ]; then
|
||||
while IFS= read -r arg || [ -n "$arg" ]; do
|
||||
[ -n "$arg" ] && extra_args+=("$arg")
|
||||
done < <(echo "$EXTRA_ARGS" | base64 -d)
|
||||
PRE_CLONE_TMP=$(mktemp)
|
||||
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP"
|
||||
chmod +x "$PRE_CLONE_TMP"
|
||||
$PRE_CLONE_TMP
|
||||
rm "$PRE_CLONE_TMP"
|
||||
fi
|
||||
|
||||
# Check if the directory is empty
|
||||
@@ -61,10 +49,18 @@ fi
|
||||
if [ -z "$(ls -A "$CLONE_PATH")" ]; then
|
||||
if [ -z "$BRANCH_NAME" ]; then
|
||||
echo "Cloning $REPO_URL to $CLONE_PATH..."
|
||||
git clone $${extra_args[@]+"$${extra_args[@]}"} "$REPO_URL" "$CLONE_PATH"
|
||||
if [ "$DEPTH" -gt 0 ]; then
|
||||
git clone --depth "$DEPTH" "$REPO_URL" "$CLONE_PATH"
|
||||
else
|
||||
git clone "$REPO_URL" "$CLONE_PATH"
|
||||
fi
|
||||
else
|
||||
echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..."
|
||||
git clone $${extra_args[@]+"$${extra_args[@]}"} -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
|
||||
if [ "$DEPTH" -gt 0 ]; then
|
||||
git clone --depth "$DEPTH" -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
|
||||
else
|
||||
git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
|
||||
@@ -73,9 +69,10 @@ fi
|
||||
# Run post-clone script if provided
|
||||
if [ -n "$POST_CLONE_SCRIPT" ]; then
|
||||
echo "Running post-clone script..."
|
||||
POST_CLONE_PATH="$SCRIPTS_DIR/post_clone.sh"
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_PATH"
|
||||
chmod +x "$POST_CLONE_PATH"
|
||||
POST_CLONE_TMP=$(mktemp)
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
|
||||
chmod +x "$POST_CLONE_TMP"
|
||||
cd "$CLONE_PATH" || exit
|
||||
"$POST_CLONE_PATH" 2>&1 | tee "$POST_CLONE_LOG_PATH"
|
||||
$POST_CLONE_TMP
|
||||
rm "$POST_CLONE_TMP"
|
||||
fi
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
icon: ../../../.icons/coder.svg
|
||||
sources:
|
||||
- repo: coder/skills@main
|
||||
skills:
|
||||
setup:
|
||||
display_name: Coder Setup
|
||||
icon: ../../../.icons/coder.svg
|
||||
tags: [coder, deployment, configuration]
|
||||
modules:
|
||||
display_name: Coder Modules
|
||||
icon: ../../../.icons/coder-modules.svg
|
||||
tags: [coder, terraform, modules]
|
||||
templates:
|
||||
display_name: Coder Templates
|
||||
icon: ../../../.icons/coder-templates.svg
|
||||
tags: [coder, terraform, templates]
|
||||
---
|
||||
|
||||
# Coder Skills
|
||||
|
||||
Agent skills maintained by [Coder](https://coder.com) for installing,
|
||||
configuring, and developing with the Coder platform.
|
||||
|
||||
Skills are sourced from [coder/skills](https://github.com/coder/skills)
|
||||
and served through the registry's API, MCP tools, and
|
||||
[well-known discovery endpoint](https://agentskills.io/specification).
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | Description |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |
|
||||
| [Coder Modules](https://registry.coder.com/skills/coder/modules) | Add or update Coder modules (from registry.coder.com/modules) inside an existing Coder template. Covers IDEs, AI agents, secrets, dev environment tools, and cloud regions. |
|
||||
| [Coder Templates](https://registry.coder.com/skills/coder/templates) | Author, edit, push, or version a Coder template. Covers starter selection, template anatomy, parameters, validation, push, and first-workspace verification. |
|
||||
Reference in New Issue
Block a user