mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76c7371ed9 | |||
| 139fadb975 | |||
| e873e43d6b | |||
| 20051c7089 | |||
| 1601ab3e8b | |||
| f9802456ce | |||
| ee219a8b17 | |||
| 4ca251f448 | |||
| 99510a1f75 | |||
| 297b07190f | |||
| bce0897099 | |||
| 6b8d89daba | |||
| c4661ae365 | |||
| 4688e4c1a7 | |||
| 4d96be0de7 | |||
| 3494da4924 | |||
| b78b65e001 | |||
| 124d05fee9 | |||
| 3b64d99fb1 | |||
| 22e574926e |
@@ -37,7 +37,7 @@ jobs:
|
||||
all:
|
||||
- '**'
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||
uses: crate-ci/typos@7b04f660f4ee4f048d18fd341887cf28dfbedfe2 # v1.46.3
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
|
||||
@@ -9,11 +9,12 @@ on:
|
||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
||||
- "release/*/*/v*.*.*"
|
||||
branches: # Templates get released when merged to main
|
||||
branches: # Templates and skills get released when merged to main
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/deploy-registry.yaml"
|
||||
- "registry/**/templates/**"
|
||||
- "registry/**/skills/**"
|
||||
- "registry/**/README.md"
|
||||
- ".icons/**"
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9
|
||||
with:
|
||||
version: v2.1
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||
|
||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
|
||||
uses: coder/coder/.github/actions/setup-tf@b98577cb911ff8a748dd6a57f5d49e4797a3c789 # v2.33.6
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (blocking, HIGH only)
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (SARIF)
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
inputs: |
|
||||
.github/workflows
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="7" x="14" y="3" rx="1"/>
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="7" x="3" y="3" rx="1"/>
|
||||
<rect width="9" height="7" x="3" y="14" rx="1"/>
|
||||
<rect width="5" height="7" x="16" y="14" rx="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -21,6 +21,84 @@ bun test main.test.ts # Run single TS test (from
|
||||
- **Templates**: `registry/[ns]/templates/[name]/` with `main.tf`, `README.md`
|
||||
- **Validation**: `cmd/readmevalidation/` (Go) validates structure/frontmatter; URLs must be relative, not absolute
|
||||
|
||||
## Module Data Layout
|
||||
|
||||
All runtime data a module writes on the workspace MUST live under a single per-module root:
|
||||
|
||||
```
|
||||
$HOME/.coder-modules/<namespace>/<module-name>/
|
||||
```
|
||||
|
||||
For a Coder-owned module named `claude-code`, the root is `$HOME/.coder-modules/coder/claude-code/`.
|
||||
|
||||
Within that root, use these standard subdirectories:
|
||||
|
||||
| Subdirectory | Purpose | Example |
|
||||
| ------------ | ----------------------------------------- | ----------------------------------------------------------- |
|
||||
| `logs/` | Output from install, start, or any script | `$HOME/.coder-modules/coder/claude-code/logs/install.log` |
|
||||
| `scripts/` | Scripts materialized at runtime (if any) | `$HOME/.coder-modules/coder/claude-code/scripts/install.sh` |
|
||||
|
||||
- Name log files after the script that produced them (`install.sh` writes to `logs/install.log`, `start.sh` writes to `logs/start.log`).
|
||||
- Always `mkdir -p` the target directory before writing; do not assume it exists.
|
||||
- Do not write module runtime data to `$HOME` directly, to ad-hoc paths like `~/.<module>-module/`, or to `/tmp/` for anything that must survive the session.
|
||||
- Tool-specific data (config files, caches, state, etc.) lives wherever the tool expects; only standardize paths the module itself controls.
|
||||
- READMEs and tests should reference paths under this root so troubleshooting has one place to look.
|
||||
- New modules MUST follow this layout. Existing modules should migrate to it when they are next touched.
|
||||
|
||||
## Use `coder-utils` for Script Orchestration
|
||||
|
||||
For any new module that runs scripts (or when reworking an existing one), use the [`coder-utils`](registry/coder/modules/coder-utils) module to orchestrate `pre_install`, `install`, `post_install`, and `start` scripts instead of hand-rolling `coder_script` resources.
|
||||
|
||||
- `coder-utils` handles script ordering via `coder exp sync`, materializes scripts under `module_directory/scripts/` (e.g., `install.sh`, `start.sh`), and writes logs to `module_directory/logs/` automatically, which aligns with the Module Data Layout above.
|
||||
- Set `module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"` so the standard root, `scripts/`, and `logs/` subdirectories fall out for free.
|
||||
|
||||
### Passing scripts to `coder-utils`
|
||||
|
||||
Store each script as a `.tftpl` file under `scripts/`. Render it at **plan time** in a `locals` block using `templatefile()`, then pass the rendered string directly to the `coder-utils` module.
|
||||
|
||||
**Encoding rules for template variables:**
|
||||
|
||||
| Value type | Terraform side | Template (`.tftpl`) side |
|
||||
| ------------------------------------- | ----------------------------------- | ---------------------------------------------- |
|
||||
| String / path | pass as-is | `ARG_FOO='${ARG_FOO}'` |
|
||||
| Boolean | `tostring(var.foo)` | `ARG_FOO='${ARG_FOO}'` |
|
||||
| Free-form string (may contain quotes) | `base64encode(var.foo)` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
|
||||
| Object / list (JSON) | `base64encode(jsonencode(var.foo))` | `ARG_FOO=$(echo -n '${ARG_FOO}' \| base64 -d)` |
|
||||
|
||||
In `.tftpl` files, write literal bash `$` as `$$` (e.g., `$${HOME}`) so Terraform does not treat them as template interpolations.
|
||||
|
||||
```tf
|
||||
locals {
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
ARG_FOO = var.foo
|
||||
ARG_BAR = var.bar
|
||||
})
|
||||
}
|
||||
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "0.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
module_directory = "$HOME/.coder-modules/<namespace>/<module-name>"
|
||||
display_name_prefix = "My Module"
|
||||
icon = var.icon
|
||||
pre_install_script = var.pre_install_script
|
||||
install_script = local.install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = var.start_script # optional; omit if the module does not start a process
|
||||
}
|
||||
```
|
||||
|
||||
Always expose the `scripts` output as a pass-through so upstream modules can serialize their own `coder_script` resources behind this module's install pipeline:
|
||||
|
||||
```tf
|
||||
output "scripts" {
|
||||
description = "Ordered list of coder exp sync names produced by this module, in run order."
|
||||
value = module.coder_utils.scripts
|
||||
}
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Every module MUST have `.tftest.hcl` tests; optional `main.test.ts` for container/script tests
|
||||
@@ -31,6 +109,28 @@ bun test main.test.ts # Run single TS test (from
|
||||
- **Do NOT include input/output variable tables in module or template READMEs.** The registry automatically generates these from the Terraform source (e.g., variable and output blocks in `main.tf`). Adding them to the README is redundant and creates maintenance drift.
|
||||
- Usage examples (e.g., a `module "..." { }` block) are encouraged, but not tables enumerating inputs/outputs.
|
||||
|
||||
### Variable and output conventions
|
||||
|
||||
Order variable blocks: `description` → `type` → `default` → `validation` → `sensitive`.
|
||||
|
||||
```tf
|
||||
variable "api_key" {
|
||||
description = "API key for the service."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
```
|
||||
|
||||
- Mark variables and outputs that hold secrets or tokens `sensitive = true`.
|
||||
- Every `output` block must have a `description`.
|
||||
- Use `count = condition ? 1 : 0` for optional singleton resources. Reserve `for_each` for maps/sets where resource identity matters.
|
||||
|
||||
### `.tftest.hcl` test commands
|
||||
|
||||
- Use `command = plan` only for assertions on **input-derived values** (variables, locals computed from inputs).
|
||||
- Use `command = apply` for **computed attributes** (resource IDs, anything the provider generates), and for nested blocks of set type (they cannot be indexed with `[0]` under `plan`).
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- Version bumped via `.github/scripts/version-bump.sh` if module changed (patch=bugfix, minor=feature, major=breaking)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used
|
||||
// in the skills README sources frontmatter. Owners and repo names allow
|
||||
// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus
|
||||
// forward slashes for paths like refs/heads/main.
|
||||
var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`)
|
||||
|
||||
// skillsIconPrefix is the relative path prefix from a skills README to the
|
||||
// repo-level .icons directory. The skills README lives at depth 3
|
||||
// (registry/<namespace>/skills/README.md), so the prefix is three levels up.
|
||||
// This is distinct from modules and templates, which live at depth 4 and use
|
||||
// "../../../../.icons/".
|
||||
const skillsIconPrefix = "../../../.icons/"
|
||||
|
||||
// skillOverride holds per-skill presentation metadata defined in the
|
||||
// registry README. All fields are optional.
|
||||
type skillOverride struct {
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Description string `yaml:"description"`
|
||||
Icon string `yaml:"icon"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// skillSource is one entry in the sources list, describing a single source
|
||||
// repo and optional per-skill overrides.
|
||||
type skillSource struct {
|
||||
Repo string `yaml:"repo"`
|
||||
Skills map[string]skillOverride `yaml:"skills"`
|
||||
}
|
||||
|
||||
// coderSkillsFrontmatter is the YAML frontmatter schema for
|
||||
// registry/<namespace>/skills/README.md.
|
||||
type coderSkillsFrontmatter struct {
|
||||
Icon string `yaml:"icon"`
|
||||
Sources []skillSource `yaml:"sources"`
|
||||
}
|
||||
|
||||
// supportedSkillsTopLevelKeys lists the keys allowed at the root of the
|
||||
// skills README frontmatter. Nested keys under sources are validated
|
||||
// separately because the typed unmarshal handles them.
|
||||
var supportedSkillsTopLevelKeys = []string{"icon", "sources"}
|
||||
|
||||
// coderSkillsReadme represents a parsed skills README file.
|
||||
type coderSkillsReadme struct {
|
||||
filePath string
|
||||
body string
|
||||
frontmatter coderSkillsFrontmatter
|
||||
}
|
||||
|
||||
// separateSkillsFrontmatter is like separateFrontmatter but preserves
|
||||
// indentation in the frontmatter block. The skills README uses nested YAML
|
||||
// (per-skill metadata under each source), which the indentation-trimming
|
||||
// behavior of the shared separateFrontmatter helper destroys.
|
||||
func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) {
|
||||
if readmeText == "" {
|
||||
return "", "", xerrors.New("README is empty")
|
||||
}
|
||||
|
||||
const fence = "---"
|
||||
var fmBuilder strings.Builder
|
||||
var bodyBuilder strings.Builder
|
||||
fenceCount := 0
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
|
||||
for lineScanner.Scan() {
|
||||
nextLine := lineScanner.Text()
|
||||
if fenceCount < 2 && strings.TrimSpace(nextLine) == fence {
|
||||
fenceCount++
|
||||
continue
|
||||
}
|
||||
if fenceCount == 0 {
|
||||
break
|
||||
}
|
||||
if fenceCount >= 2 {
|
||||
bodyBuilder.WriteString(nextLine)
|
||||
bodyBuilder.WriteString("\n")
|
||||
} else {
|
||||
fmBuilder.WriteString(nextLine)
|
||||
fmBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if fenceCount < 2 {
|
||||
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
|
||||
}
|
||||
if strings.TrimSpace(fmBuilder.String()) == "" {
|
||||
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
|
||||
}
|
||||
|
||||
return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil
|
||||
}
|
||||
|
||||
// isPermittedSkillsIconURL validates that an icon URL references the
|
||||
// repo-level .icons directory using the 3-deep prefix appropriate for
|
||||
// skills READMEs, and that the file exists on disk.
|
||||
func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error {
|
||||
if !strings.HasPrefix(checkURL, skillsIconPrefix) {
|
||||
return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix)
|
||||
}
|
||||
|
||||
readmeDir := path.Dir(readmeFilePath)
|
||||
resolvedPath := path.Join(readmeDir, checkURL)
|
||||
|
||||
if _, err := os.Stat(resolvedPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
|
||||
}
|
||||
return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSkillsIconURL(iconURL string, filePath string) []error {
|
||||
if iconURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
|
||||
errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
|
||||
return errs
|
||||
}
|
||||
|
||||
if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter
|
||||
// as a YAML map and verifies that every top-level key is in the supported
|
||||
// set. This catches typos like "source:" vs "sources:".
|
||||
func validateSkillsTopLevelKeys(fm string) []error {
|
||||
var rawKeys map[string]any
|
||||
if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil {
|
||||
return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for key := range rawKeys {
|
||||
if !slices.Contains(supportedSkillsTopLevelKeys, key) {
|
||||
errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", ")))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateSkillsSources(sources []skillSource, filePath string) []error {
|
||||
if len(sources) == 0 {
|
||||
return []error{xerrors.New("at least one source repo is required under 'sources'")}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for i, src := range sources {
|
||||
if src.Repo == "" {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i))
|
||||
continue
|
||||
}
|
||||
if !skillsRepoSpecRe.MatchString(src.Repo) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo))
|
||||
}
|
||||
|
||||
for slug, override := range src.Skills {
|
||||
if !validNameRe.MatchString(slug) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug))
|
||||
}
|
||||
|
||||
for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr))
|
||||
}
|
||||
|
||||
// validateCoderResourceTags returns an error for nil tags, which is
|
||||
// fine for modules/templates that require tags but not for skills
|
||||
// where tags are an optional override.
|
||||
if override.Tags != nil {
|
||||
if err := validateCoderResourceTags(override.Tags); err != nil {
|
||||
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error {
|
||||
var errs []error
|
||||
|
||||
for _, err := range validateSkillsIconURL(fm.Icon, filePath) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
for _, err := range validateSkillsSources(fm.Sources, filePath) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) {
|
||||
fm, body, err := separateSkillsFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateSkillsTopLevelKeys(fm)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return coderSkillsReadme{}, remapped
|
||||
}
|
||||
|
||||
yml := coderSkillsFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return coderSkillsReadme{
|
||||
filePath: rm.filePath,
|
||||
body: body,
|
||||
frontmatter: yml,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) {
|
||||
var parsed []coderSkillsReadme
|
||||
var parsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, errs := parseCoderSkillsReadme(rm)
|
||||
if len(errs) != 0 {
|
||||
parsingErrs = append(parsingErrs, errs...)
|
||||
continue
|
||||
}
|
||||
parsed = append(parsed, p)
|
||||
}
|
||||
if len(parsingErrs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: parsingErrs,
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error {
|
||||
var validationErrs []error
|
||||
for _, rm := range readmes {
|
||||
errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter)
|
||||
if len(errs) > 0 {
|
||||
validationErrs = append(validationErrs, errs...)
|
||||
}
|
||||
}
|
||||
if len(validationErrs) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: validationErrs,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateSkillsReadmeFiles walks registry/<namespace>/skills/README.md
|
||||
// entries, skipping namespaces that do not have a skills directory.
|
||||
func aggregateSkillsReadmeFiles() ([]readme, error) {
|
||||
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
for _, nDir := range namespaceDirs {
|
||||
if !nDir.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md")
|
||||
rmBytes, err := os.ReadFile(skillsReadmePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
allReadmeFiles = append(allReadmeFiles, readme{
|
||||
filePath: skillsReadmePath,
|
||||
rawText: string(rmBytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseFile,
|
||||
errors: errs,
|
||||
}
|
||||
}
|
||||
return allReadmeFiles, nil
|
||||
}
|
||||
|
||||
func validateAllCoderSkills() error {
|
||||
allReadmeFiles, err := aggregateSkillsReadmeFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
|
||||
if len(allReadmeFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
|
||||
return nil
|
||||
}
|
||||
@@ -39,6 +39,10 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderSkills()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
|
||||
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
@@ -1,149 +1,107 @@
|
||||
---
|
||||
display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
description: Install and configure the Codex CLI in your workspace.
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks, aibridge]
|
||||
tags: [agent, codex, ai, openai, ai-gateway]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
|
||||
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
Install and configure the [Codex CLI](https://github.com/openai/codex) in your workspace.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
version = "5.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenAI API key for Codex access
|
||||
> [!WARNING]
|
||||
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide.
|
||||
|
||||
## Examples
|
||||
|
||||
### Run standalone
|
||||
### Standalone mode with a launcher app
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
workdir = "/home/coder/project"
|
||||
report_tasks = false
|
||||
locals {
|
||||
codex_workdir = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.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 aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token
|
||||
|
||||
```toml
|
||||
model_provider = "aibridge"
|
||||
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "https://example.coder.com/api/v2/aibridge/openai/v1"
|
||||
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
data "coder_task" "me" {}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
# Optional: route through AI Bridge (Premium feature)
|
||||
# enable_aibridge = true
|
||||
version = "5.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = local.codex_workdir
|
||||
openai_api_key = var.openai_api_key
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Agent Boundaries
|
||||
|
||||
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
|
||||
|
||||
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "4.3.1"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
resource "coder_app" "codex" {
|
||||
agent_id = coder_agent.main.id
|
||||
slug = "codex"
|
||||
display_name = "Codex"
|
||||
icon = "/icon/openai.svg"
|
||||
open_in = "slim-window"
|
||||
command = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "${local.codex_workdir}"
|
||||
codex
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!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.
|
||||
> The `coder_app` command re-executes on every pane reconnect. This works for interactive `codex` (which stays alive), but one-shot commands like `codex exec` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (e.g. via tmux/screen).
|
||||
|
||||
### Usage with AI Gateway
|
||||
|
||||
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_ai_gateway = true
|
||||
}
|
||||
```
|
||||
|
||||
When `enable_ai_gateway = true`, the module configures Codex to use the `aigateway` model provider in `config.toml` with the workspace owner's session token for authentication.
|
||||
|
||||
> [!CAUTION]
|
||||
> `enable_ai_gateway = true` is mutually exclusive with `openai_api_key`. Setting both fails at plan time.
|
||||
|
||||
> [!NOTE]
|
||||
> If you provide a custom `base_config_toml`, the module writes it verbatim and does not inject `model_provider = "aigateway"` automatically. Add it to your config yourself:
|
||||
>
|
||||
> ```toml
|
||||
> model_provider = "aigateway"
|
||||
> ```
|
||||
|
||||
### 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.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
version = "5.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
openai_api_key = var.openai_api_key
|
||||
|
||||
codex_version = "0.1.0" # Pin to a specific version
|
||||
codex_model = "gpt-4o" # Custom model
|
||||
codex_version = "0.128.0"
|
||||
|
||||
# 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 = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
@@ -152,61 +110,49 @@ module "codex" {
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
### Serialize a downstream `coder_script` after the install pipeline
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: The module installs Codex CLI and sets up the environment
|
||||
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
|
||||
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
|
||||
- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions.
|
||||
|
||||
## State Persistence
|
||||
|
||||
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
|
||||
|
||||
To disable:
|
||||
The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
# ... other config
|
||||
enable_state_persistence = false
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "5.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
openai_api_key = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "coder_script" "post_codex" {
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Run after Codex install"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
trap 'coder exp sync complete post-codex' EXIT
|
||||
coder exp sync want post-codex ${join(" ", module.codex.scripts)}
|
||||
coder exp sync start post-codex
|
||||
|
||||
codex --version
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration
|
||||
|
||||
When no custom `base_config_toml` is provided, the module uses these secure defaults:
|
||||
|
||||
```toml
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
```
|
||||
|
||||
> [!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).
|
||||
When no custom `base_config_toml` is provided, the module uses a minimal default with `preferred_auth_method = "apikey"`. For advanced options, see [Codex config docs](https://developers.openai.com/codex/config-advanced).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check installation and startup logs in `~/.codex-module/`
|
||||
- Ensure your OpenAI API key has access to the specified model
|
||||
Check the log files in `~/.coder-modules/coder-labs/codex/logs/` for detailed information.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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.
|
||||
```bash
|
||||
cat ~/.coder-modules/coder-labs/codex/logs/install.log
|
||||
cat ~/.coder-modules/coder-labs/codex/logs/pre_install.log
|
||||
cat ~/.coder-modules/coder-labs/codex/logs/post_install.log
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [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)
|
||||
- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway)
|
||||
|
||||
@@ -6,15 +6,67 @@ import {
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
execContainer,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
TerraformState,
|
||||
} from "~test";
|
||||
import {
|
||||
extractCoderEnvVars,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
import path from "path";
|
||||
|
||||
interface ModuleScripts {
|
||||
pre_install?: string;
|
||||
install: string;
|
||||
post_install?: string;
|
||||
}
|
||||
|
||||
const SCRIPT_SUFFIXES = [
|
||||
"Pre-Install Script",
|
||||
"Install Script",
|
||||
"Post-Install Script",
|
||||
] as const;
|
||||
|
||||
const collectScripts = (state: TerraformState): ModuleScripts => {
|
||||
const byDisplayName: Record<string, string> = {};
|
||||
for (const resource of state.resources) {
|
||||
if (resource.type !== "coder_script") continue;
|
||||
for (const instance of resource.instances) {
|
||||
const attrs = instance.attributes as Record<string, unknown>;
|
||||
const displayName = attrs.display_name as string | undefined;
|
||||
const script = attrs.script as string | undefined;
|
||||
if (displayName && script) {
|
||||
byDisplayName[displayName] = script;
|
||||
}
|
||||
}
|
||||
}
|
||||
const scripts: Partial<ModuleScripts> = {};
|
||||
for (const suffix of SCRIPT_SUFFIXES) {
|
||||
const key = `Codex: ${suffix}`;
|
||||
if (!(key in byDisplayName)) continue;
|
||||
switch (suffix) {
|
||||
case "Pre-Install Script":
|
||||
scripts.pre_install = byDisplayName[key];
|
||||
break;
|
||||
case "Install Script":
|
||||
scripts.install = byDisplayName[key];
|
||||
break;
|
||||
case "Post-Install Script":
|
||||
scripts.post_install = byDisplayName[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!scripts.install) {
|
||||
throw new Error("install script not found in terraform state");
|
||||
}
|
||||
return scripts as ModuleScripts;
|
||||
};
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
@@ -33,36 +85,90 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCodexMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const setup = async (
|
||||
props?: SetupProps,
|
||||
): Promise<{
|
||||
id: string;
|
||||
coderEnvVars: Record<string, string>;
|
||||
scripts: ModuleScripts;
|
||||
}> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_codex: props?.skipCodexMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
codex_model: "gpt-4-turbo",
|
||||
workdir: "/home/coder",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
const moduleDir = path.resolve(import.meta.dir);
|
||||
const state = await runTerraformApply(moduleDir, {
|
||||
agent_id: "foo",
|
||||
workdir: projectDir,
|
||||
install_codex: "false",
|
||||
...props?.moduleVariables,
|
||||
});
|
||||
const scripts = collectScripts(state);
|
||||
const coderEnvVars = extractCoderEnvVars(state);
|
||||
|
||||
const id = await runContainer("codercom/enterprise-node:latest");
|
||||
registerCleanup(async () => {
|
||||
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
|
||||
console.log(`Not removing container ${id} in debug mode`);
|
||||
return;
|
||||
}
|
||||
await removeContainer(id);
|
||||
});
|
||||
|
||||
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: "#!/bin/bash\nexit 0\n",
|
||||
});
|
||||
if (!props?.skipCodexMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/codex",
|
||||
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
|
||||
content: await Bun.file(
|
||||
path.join(moduleDir, "testdata", "codex-mock.sh"),
|
||||
).text(),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
return { id, coderEnvVars, scripts };
|
||||
};
|
||||
|
||||
const runScripts = async (
|
||||
id: string,
|
||||
scripts: ModuleScripts,
|
||||
env?: Record<string, string>,
|
||||
) => {
|
||||
const entries = env ? Object.entries(env) : [];
|
||||
const envArgs =
|
||||
entries.length > 0
|
||||
? entries
|
||||
.map(
|
||||
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
|
||||
)
|
||||
.join(" && ") + " && "
|
||||
: "";
|
||||
const ordered: [string, string | undefined][] = [
|
||||
["pre_install", scripts.pre_install],
|
||||
["install", scripts.install],
|
||||
["post_install", scripts.post_install],
|
||||
];
|
||||
for (const [name, script] of ordered) {
|
||||
if (!script) continue;
|
||||
const target = `/tmp/coder-utils-${name}.sh`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: target,
|
||||
content: script,
|
||||
});
|
||||
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(`script ${name} failed:`);
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
@@ -73,444 +179,269 @@ describe("codex", async () => {
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const { id, scripts } = await setup();
|
||||
await runScripts(id, scripts);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Skipping Codex installation");
|
||||
});
|
||||
|
||||
test("install-codex-version", async () => {
|
||||
const version_to_install = "0.10.0";
|
||||
const { id } = await setup({
|
||||
const version = "0.10.0";
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
codex_version: version_to_install,
|
||||
codex_version: version,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain(version);
|
||||
});
|
||||
|
||||
test("check-latest-codex-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("base-config-toml", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[custom_section]
|
||||
new_feature = true
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain('sandbox_mode = "danger-full-access"');
|
||||
expect(resp).toContain('preferred_auth_method = "apikey"');
|
||||
expect(resp).toContain("[custom_section]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
});
|
||||
|
||||
test("codex-api-key", async () => {
|
||||
test("openai-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
openai_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey);
|
||||
});
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("OpenAI API Key: Provided");
|
||||
test("base-config-toml", async () => {
|
||||
const baseConfig = [
|
||||
'sandbox_mode = "danger-full-access"',
|
||||
'approval_policy = "never"',
|
||||
'preferred_auth_method = "apikey"',
|
||||
"",
|
||||
"[custom_section]",
|
||||
"new_feature = true",
|
||||
].join("\n");
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain('sandbox_mode = "danger-full-access"');
|
||||
expect(resp).toContain('preferred_auth_method = "apikey"');
|
||||
expect(resp).toContain("[custom_section]");
|
||||
});
|
||||
|
||||
test("additional-mcp-servers", async () => {
|
||||
const additional = [
|
||||
"[mcp_servers.GitHub]",
|
||||
'command = "npx"',
|
||||
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
||||
'type = "stdio"',
|
||||
'description = "GitHub integration"',
|
||||
].join("\n");
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: additional,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.GitHub]");
|
||||
expect(resp).toContain("GitHub integration");
|
||||
});
|
||||
|
||||
test("minimal-default-config", async () => {
|
||||
const { id, scripts } = await setup();
|
||||
await runScripts(id, scripts);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain('preferred_auth_method = "apikey"');
|
||||
expect(resp).not.toContain("model_provider");
|
||||
expect(resp).not.toContain("[model_providers.");
|
||||
expect(resp).not.toContain("model_reasoning_effort");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'codex-post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/pre_install.log",
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("pre-install-script");
|
||||
expect(preInstallLog).toContain("codex-pre-install-script");
|
||||
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/post_install.log",
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
expect(postInstallLog).toContain("codex-post-install-script");
|
||||
});
|
||||
|
||||
test("workdir-variable", async () => {
|
||||
const workdir = "/tmp/codex-test-workdir";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: false,
|
||||
const workdir = "/home/coder/codex-test-folder";
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
await runScripts(id, scripts);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/install.log",
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
||||
);
|
||||
expect(resp).toContain(workdir);
|
||||
expect(installLog).toContain(workdir);
|
||||
});
|
||||
|
||||
test("additional-mcp-servers", async () => {
|
||||
const additional = dedent`
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
description = "GitHub integration"
|
||||
|
||||
[mcp_servers.FileSystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
type = "stdio"
|
||||
description = "File system access"
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
test("codex-with-ai-gateway", async () => {
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
additional_mcp_servers: additional,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.GitHub]");
|
||||
expect(resp).toContain("[mcp_servers.FileSystem]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("GitHub integration");
|
||||
});
|
||||
|
||||
test("full-custom-config", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "read-only"
|
||||
approval_policy = "untrusted"
|
||||
preferred_auth_method = "chatgpt"
|
||||
custom_setting = "test-value"
|
||||
|
||||
[advanced_settings]
|
||||
timeout = 30000
|
||||
debug = true
|
||||
logging_level = "verbose"
|
||||
`.trim();
|
||||
|
||||
const additionalMCP = dedent`
|
||||
[mcp_servers.CustomTool]
|
||||
command = "/usr/local/bin/custom-tool"
|
||||
args = ["--serve", "--port", "8080"]
|
||||
type = "stdio"
|
||||
description = "Custom development tool"
|
||||
|
||||
[mcp_servers.DatabaseMCP]
|
||||
command = "python"
|
||||
args = ["-m", "database_mcp_server"]
|
||||
type = "stdio"
|
||||
description = "Database query interface"
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
additional_mcp_servers: additionalMCP,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check base config
|
||||
expect(resp).toContain('sandbox_mode = "read-only"');
|
||||
expect(resp).toContain('preferred_auth_method = "chatgpt"');
|
||||
expect(resp).toContain('custom_setting = "test-value"');
|
||||
expect(resp).toContain("[advanced_settings]");
|
||||
expect(resp).toContain('logging_level = "verbose"');
|
||||
|
||||
// Check MCP servers
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("[mcp_servers.CustomTool]");
|
||||
expect(resp).toContain("[mcp_servers.DatabaseMCP]");
|
||||
expect(resp).toContain("Custom development tool");
|
||||
expect(resp).toContain("Database query interface");
|
||||
});
|
||||
|
||||
test("minimal-default-config", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
// No base_config_toml or additional_mcp_servers - should use defaults
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check default base config
|
||||
expect(resp).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(resp).toContain('approval_policy = "never"');
|
||||
expect(resp).toContain("[sandbox_workspace_write]");
|
||||
expect(resp).toContain("network_access = true");
|
||||
|
||||
// Check only Coder MCP server is present
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("Report ALL tasks and statuses");
|
||||
|
||||
// Ensure no additional MCP servers
|
||||
const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length;
|
||||
expect(mcpServerCount).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-system-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("codex-system-prompt-skip-append-if-exists", async () => {
|
||||
const prompt_1 = "This is a system prompt for Codex.";
|
||||
const prompt_2 = "This is a system prompt for Goose.";
|
||||
const prompt_3 = dedent`
|
||||
This is a system prompt for Codex.
|
||||
This is a system prompt for Gemini.
|
||||
`.trim();
|
||||
const pre_install_script = dedent`
|
||||
#!/bin/bash
|
||||
mkdir -p /home/coder/.codex
|
||||
echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_2,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt_1);
|
||||
expect(resp).toContain(prompt_2);
|
||||
|
||||
// Re-run with a prompt that already exists, it should not append again
|
||||
const { id: id_2 } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_1,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id_2);
|
||||
const resp_2 = await readFileContainer(
|
||||
id_2,
|
||||
"/home/coder/.codex/AGENTS.md",
|
||||
);
|
||||
expect(resp_2).toContain(prompt_1);
|
||||
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-ai-task-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: "", // Explicitly disable system prompt
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const prompt = await execContainer(id, [
|
||||
"ls",
|
||||
"-l",
|
||||
"/home/coder/.codex/AGENTS.md",
|
||||
]);
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
|
||||
test("codex-continue-capture-new-session", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
ai_prompt: "test task",
|
||||
},
|
||||
});
|
||||
|
||||
const workdir = "/home/coder";
|
||||
const expectedSessionId = "019a1234-5678-9abc-def0-123456789012";
|
||||
const sessionsDir = "/home/coder/.codex/sessions";
|
||||
const sessionFile = `${sessionsDir}/${expectedSessionId}.jsonl`;
|
||||
|
||||
await execContainer(id, ["mkdir", "-p", sessionsDir]);
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`echo '{"id":"${expectedSessionId}","cwd":"${workdir}","created":"2024-10-24T20:00:00Z","model":"gpt-4-turbo"}' > ${sessionFile}`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
|
||||
const maxAttempts = 30;
|
||||
let trackingFileContents = "";
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const result = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat ${trackingFile} 2>/dev/null || echo ""`,
|
||||
]);
|
||||
if (result.stdout.trim().length > 0) {
|
||||
trackingFileContents = result.stdout;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
expect(trackingFileContents).toContain(`${workdir}|${expectedSessionId}`);
|
||||
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("Capturing new session ID");
|
||||
expect(startLog).toContain("Session tracked");
|
||||
expect(startLog).toContain(expectedSessionId);
|
||||
});
|
||||
|
||||
test("codex-continue-resume-existing-session", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
|
||||
const workdir = "/home/coder";
|
||||
const mockSessionId = "019a1234-5678-9abc-def0-123456789012";
|
||||
const trackingFile = "/home/coder/.codex-module/.codex-task-session";
|
||||
|
||||
await execContainer(id, ["mkdir", "-p", "/home/coder/.codex-module"]);
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`echo "${workdir}|${mockSessionId}" > ${trackingFile}`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.codex-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("Found existing task session");
|
||||
expect(startLog.stdout).toContain(mockSessionId);
|
||||
expect(startLog.stdout).toContain("Resuming existing session");
|
||||
expect(startLog.stdout).toContain(
|
||||
`Starting Codex with arguments: --model gpt-4-turbo resume ${mockSessionId}`,
|
||||
);
|
||||
expect(startLog.stdout).not.toContain("test prompt");
|
||||
});
|
||||
|
||||
test("codex-with-aibridge", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_aibridge: "true",
|
||||
enable_ai_gateway: "true",
|
||||
model_reasoning_effort: "none",
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).toContain('model_provider = "aibridge"');
|
||||
expect(configToml).toContain('model_provider = "aigateway"');
|
||||
expect(configToml).toContain('model_reasoning_effort = "none"');
|
||||
expect(configToml).toContain("[model_providers.aigateway]");
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
test("model-reasoning-effort-standalone", async () => {
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
model_reasoning_effort: "high",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.openai.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
if [ "$2" = "--help" ]; then
|
||||
echo "boundary help"
|
||||
exit 0
|
||||
fi
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify boundary wrapper was used in start script
|
||||
const startLog = await readFileContainer(
|
||||
await runScripts(id, scripts);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(startLog).toContain("boundary");
|
||||
expect(configToml).toContain('model_reasoning_effort = "high"');
|
||||
expect(configToml).not.toContain("model_provider");
|
||||
});
|
||||
|
||||
test("workdir-trusted-project", async () => {
|
||||
const workdir = "/home/coder/trusted-project";
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).toContain(`[projects."${workdir}"]`);
|
||||
expect(configToml).toContain('trust_level = "trusted"');
|
||||
});
|
||||
|
||||
test("no-workdir-no-project-section", async () => {
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
workdir: "",
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).not.toContain("[projects.");
|
||||
});
|
||||
|
||||
test("ai-gateway-with-custom-base-config", async () => {
|
||||
const baseConfig = [
|
||||
'sandbox_mode = "danger-full-access"',
|
||||
'model_provider = "aigateway"',
|
||||
].join("\n");
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
enable_ai_gateway: "true",
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).toContain('model_provider = "aigateway"');
|
||||
expect(configToml).toContain("[model_providers.aigateway]");
|
||||
});
|
||||
|
||||
test("ai-gateway-custom-config-no-duplicate-provider", async () => {
|
||||
const baseConfig = [
|
||||
'model_provider = "aigateway"',
|
||||
"",
|
||||
"[model_providers.aigateway]",
|
||||
'name = "Custom AI Bridge"',
|
||||
'base_url = "https://custom.example.com"',
|
||||
'env_key = "CODER_AIBRIDGE_SESSION_TOKEN"',
|
||||
'wire_api = "responses"',
|
||||
].join("\n");
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
enable_ai_gateway: "true",
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
const matches = configToml.match(/\[model_providers\.aigateway\]/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(configToml).toContain("Custom AI Bridge");
|
||||
});
|
||||
|
||||
test("install-codex-latest", async () => {
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Installed Codex CLI");
|
||||
});
|
||||
|
||||
test("custom-config-drops-reasoning-effort", async () => {
|
||||
const baseConfig = [
|
||||
'sandbox_mode = "danger-full-access"',
|
||||
'preferred_auth_method = "apikey"',
|
||||
].join("\n");
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
model_reasoning_effort: "high",
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
const configToml = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex/config.toml",
|
||||
);
|
||||
expect(configToml).toContain('sandbox_mode = "danger-full-access"');
|
||||
expect(configToml).not.toContain("model_reasoning_effort");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,18 +18,6 @@ data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
@@ -38,106 +26,8 @@ variable "icon" {
|
||||
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run Codex in."
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Codex"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Codex"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
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 model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
|
||||
default = ""
|
||||
validation {
|
||||
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
|
||||
error_message = "model_reasoning_effort must be one of: none, low, medium, high."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "codex_version" {
|
||||
type = string
|
||||
description = "The version of Codex to install."
|
||||
default = "" # empty string means the latest available version
|
||||
}
|
||||
|
||||
variable "base_config_toml" {
|
||||
type = string
|
||||
description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "openai_api_key" {
|
||||
type = string
|
||||
description = "OpenAI API key for Codex CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.12.1"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5.3-codex."
|
||||
default = "gpt-5.4"
|
||||
description = "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted project in Codex config.toml."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
@@ -152,158 +42,127 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for Codex CLI when launched via Tasks"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "continue" {
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
|
||||
description = "Whether to install Codex."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_state_persistence" {
|
||||
type = bool
|
||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "codex_system_prompt" {
|
||||
variable "codex_version" {
|
||||
type = string
|
||||
description = "System instructions written to AGENTS.md in the ~/.codex directory"
|
||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
|
||||
description = "The version of Codex to install."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script."
|
||||
default = false
|
||||
variable "openai_api_key" {
|
||||
type = string
|
||||
description = "OpenAI API key for Codex CLI."
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
variable "base_config_toml" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
Complete base TOML configuration for Codex (without mcp_servers section).
|
||||
When empty, the module generates a minimal default:
|
||||
|
||||
preferred_auth_method = "apikey"
|
||||
# model_provider = "aigateway" (sets the default profile, when enable_ai_gateway = true)
|
||||
# model_reasoning_effort = "<value>" (sets the reasoning effort, when model_reasoning_effort is set)
|
||||
|
||||
[projects."<workdir>"] (when workdir is set)
|
||||
trust_level = "trusted"
|
||||
|
||||
When non-empty, the value is written verbatim as the base of config.toml;
|
||||
mcp and AI Gateway sections are still appended after it.
|
||||
Note: model_reasoning_effort and workdir trust are only applied in the
|
||||
default config. Include them in your custom config if needed.
|
||||
EOT
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "model_reasoning_effort" {
|
||||
type = string
|
||||
description = "The reasoning effort for the model. One of: none, minimal, low, medium, high, xhigh. See https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort"
|
||||
default = ""
|
||||
validation {
|
||||
condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort)
|
||||
error_message = "model_reasoning_effort must be one of: none, minimal, low, medium, high, xhigh."
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_ai_gateway" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
|
||||
description = "Use AI Gateway for Codex. https://coder.com/docs/ai-coder/ai-gateway"
|
||||
default = false
|
||||
|
||||
validation {
|
||||
condition = !(var.enable_ai_gateway && length(var.openai_api_key) > 0)
|
||||
error_message = "openai_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials."
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
count = var.openai_api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
value = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "coder_aibridge_session_token" {
|
||||
count = var.enable_aibridge ? 1 : 0
|
||||
# Authenticates the client against Coder's AI Gateway using the workspace
|
||||
# owner's session token. Referenced by config.toml model_providers.aigateway.
|
||||
resource "coder_env" "ai_gateway_session_token" {
|
||||
count = var.enable_ai_gateway ? 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"
|
||||
latest_codex_model = "gpt-5.4"
|
||||
aibridge_config = <<-EOF
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
|
||||
aibridge_config = <<-EOF
|
||||
[model_providers.aigateway]
|
||||
name = "AI Gateway"
|
||||
base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
|
||||
env_key = "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
wire_api = "responses"
|
||||
|
||||
EOF
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
ARG_INSTALL = tostring(var.install_codex)
|
||||
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
|
||||
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
|
||||
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
|
||||
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
|
||||
ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
|
||||
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort
|
||||
ARG_OPENAI_API_KEY = var.openai_api_key != "" ? base64encode(var.openai_api_key) : ""
|
||||
})
|
||||
module_dir_name = ".coder-modules/coder-labs/codex"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.3.0"
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "0.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
enable_boundary = var.enable_boundary
|
||||
boundary_config_path = var.boundary_config_path
|
||||
boundary_version = var.boundary_version
|
||||
compile_boundary_from_source = var.compile_boundary_from_source
|
||||
use_boundary_directly = var.use_boundary_directly
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
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
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \
|
||||
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_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
agent_id = var.agent_id
|
||||
module_directory = "$HOME/${local.module_dir_name}"
|
||||
display_name_prefix = "Codex"
|
||||
icon = var.icon
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
install_script = local.install_script
|
||||
}
|
||||
|
||||
output "task_app_id" {
|
||||
value = module.agentapi.task_app_id
|
||||
output "scripts" {
|
||||
description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
|
||||
value = module.coder_utils.scripts
|
||||
}
|
||||
|
||||
@@ -1,187 +1,185 @@
|
||||
run "test_codex_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
openai_api_key = "test-key"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent"
|
||||
error_message = "Agent ID should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.workdir == "/home/coder"
|
||||
error_message = "Workdir should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_codex == true
|
||||
error_message = "install_codex should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_agentapi == true
|
||||
error_message = "install_agentapi should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.report_tasks == true
|
||||
error_message = "report_tasks should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue == true
|
||||
error_message = "continue should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_enable_state_persistence_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
openai_api_key = "test-key"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == true
|
||||
error_message = "enable_state_persistence should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_disable_state_persistence" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
openai_api_key = "test-key"
|
||||
enable_state_persistence = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == false
|
||||
error_message = "enable_state_persistence should be false when explicitly disabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_codex_with_aibridge" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
enable_aibridge = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == true
|
||||
error_message = "enable_aibridge should be set to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_aibridge_disabled_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
openai_api_key = "test-key"
|
||||
enable_aibridge = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == false
|
||||
error_message = "enable_aibridge should be false"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.openai_api_key.value == "test-key"
|
||||
error_message = "OpenAI API key should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder/project"
|
||||
openai_api_key = "test-key"
|
||||
order = 5
|
||||
group = "ai-tools"
|
||||
icon = "/icon/custom.svg"
|
||||
web_app_display_name = "Custom Codex"
|
||||
cli_app = true
|
||||
cli_app_display_name = "Codex Terminal"
|
||||
subdomain = true
|
||||
report_tasks = false
|
||||
continue = false
|
||||
codex_model = "gpt-4o"
|
||||
codex_version = "0.1.0"
|
||||
agentapi_version = "v0.12.0"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "ai-tools"
|
||||
error_message = "Group should be set to 'ai-tools'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.cli_app == true
|
||||
error_message = "cli_app should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.subdomain == true
|
||||
error_message = "subdomain should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.report_tasks == false
|
||||
error_message = "report_tasks should be disabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue == false
|
||||
error_message = "continue should be disabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.codex_model == "gpt-4o"
|
||||
error_message = "codex_model should be set to 'gpt-4o'"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_no_api_key_no_aibridge" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.openai_api_key == ""
|
||||
error_message = "openai_api_key should be empty when not provided"
|
||||
condition = var.install_codex == true
|
||||
error_message = "install_codex should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_codex_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
openai_api_key = "test-key"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == false
|
||||
error_message = "enable_aibridge should default to false"
|
||||
condition = coder_env.openai_api_key[0].value == "test-key"
|
||||
error_message = "OpenAI API key should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_codex_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder/project"
|
||||
icon = "/icon/custom.svg"
|
||||
codex_version = "0.128.0"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) > 0
|
||||
error_message = "scripts output should be non-empty with custom options"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_ai_gateway_enabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
enable_ai_gateway = true
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_workspace_owner.me
|
||||
values = {
|
||||
session_token = "mock-session-token"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.ai_gateway_session_token[0].name == "CODER_AIBRIDGE_SESSION_TOKEN"
|
||||
error_message = "CODER_AIBRIDGE_SESSION_TOKEN should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.ai_gateway_session_token[0].value == data.coder_workspace_owner.me.session_token
|
||||
error_message = "Session token should use workspace owner's token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.openai_api_key) == 0
|
||||
error_message = "OPENAI_API_KEY should not be created when ai_gateway is enabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_ai_gateway_validation_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
enable_ai_gateway = true
|
||||
openai_api_key = "test-key"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.enable_ai_gateway,
|
||||
]
|
||||
}
|
||||
|
||||
run "test_ai_gateway_disabled_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
enable_ai_gateway = false
|
||||
openai_api_key = "test-key-xyz"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.openai_api_key[0].value == "test-key-xyz"
|
||||
error_message = "OPENAI_API_KEY should use the provided API key"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.ai_gateway_session_token) == 0
|
||||
error_message = "Session token should not be set when ai_gateway is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_no_api_key_no_env" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.openai_api_key) == 0
|
||||
error_message = "OPENAI_API_KEY should not be created when no API key is provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_codex_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) == 3
|
||||
error_message = "scripts output should have 3 entries when pre/post are configured"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_script_outputs_install_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-labs-codex-install_script"
|
||||
error_message = "scripts output should list only the install script when pre/post are not configured"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_script_outputs_with_pre_and_post" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
pre_install_script = "echo pre"
|
||||
post_install_script = "echo post"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts == ["coder-labs-codex-pre_install_script", "coder-labs-codex-install_script", "coder-labs-codex-post_install_script"]
|
||||
error_message = "scripts output should list pre_install, install, post_install in run order"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_workdir_optional" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) == 1
|
||||
error_message = "scripts output should have install script even without workdir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
#!/bin/bash
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
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"
|
||||
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
|
||||
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
|
||||
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
|
||||
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE"
|
||||
echo "======================================"
|
||||
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_codex() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "${BOLD}"
|
||||
|
||||
if [ -n "$ARG_CODEX_VERSION" ]; then
|
||||
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
|
||||
else
|
||||
npm install -g "@openai/codex"
|
||||
fi
|
||||
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
write_minimal_default_config() {
|
||||
local config_path="$1"
|
||||
|
||||
ARG_OPTIONAL_TOP_LEVEL_CONFIG=""
|
||||
|
||||
if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then
|
||||
ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"'
|
||||
fi
|
||||
|
||||
if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then
|
||||
ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\""
|
||||
fi
|
||||
|
||||
cat << EOF > "$config_path"
|
||||
# Minimal Default Codex Configuration
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
${ARG_OPTIONAL_TOP_LEVEL_CONFIG}
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
|
||||
[notice.model_migrations]
|
||||
"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}"
|
||||
|
||||
[projects."${ARG_CODEX_START_DIRECTORY}"]
|
||||
trust_level = "trusted"
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
append_mcp_servers_section() {
|
||||
local config_path="$1"
|
||||
|
||||
if [ "${ARG_REPORT_TASKS}" == "false" ]; then
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=""
|
||||
CODER_MCP_AI_AGENTAPI_URL=""
|
||||
else
|
||||
CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
fi
|
||||
|
||||
cat << EOF >> "$config_path"
|
||||
|
||||
# MCP Servers Configuration
|
||||
[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}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
type = "stdio"
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
|
||||
printf "Adding additional MCP servers\n"
|
||||
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
|
||||
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")"
|
||||
|
||||
if [ -n "$ARG_BASE_CONFIG_TOML" ]; then
|
||||
printf "Using provided base configuration\n"
|
||||
echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH"
|
||||
else
|
||||
printf "Using minimal default configuration\n"
|
||||
write_minimal_default_config "$CONFIG_PATH"
|
||||
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() {
|
||||
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
|
||||
AGENTS_PATH="$HOME/.codex/AGENTS.md"
|
||||
printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}"
|
||||
|
||||
mkdir -p "$HOME/.codex"
|
||||
|
||||
if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then
|
||||
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
|
||||
else
|
||||
printf "Appending instruction prompt to AGENTS.md in .codex directory\n"
|
||||
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}"
|
||||
fi
|
||||
|
||||
if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
printf "AGENTS.md instruction prompt is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function add_auth_json() {
|
||||
AUTH_JSON_PATH="$HOME/.codex/auth.json"
|
||||
mkdir -p "$(dirname "$AUTH_JSON_PATH")"
|
||||
AUTH_JSON=$(
|
||||
cat << EOF
|
||||
{
|
||||
"OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
echo "$AUTH_JSON" > "$AUTH_JSON_PATH"
|
||||
}
|
||||
|
||||
install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
|
||||
if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
|
||||
add_auth_json
|
||||
fi
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_INSTALL='${ARG_INSTALL}'
|
||||
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
|
||||
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
|
||||
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
|
||||
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
|
||||
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
|
||||
ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
|
||||
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
|
||||
ARG_OPENAI_API_KEY=$(echo -n '${ARG_OPENAI_API_KEY}' | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "codex_version: %s\n" "$${ARG_CODEX_VERSION}"
|
||||
printf "workdir: %s\n" "$${ARG_WORKDIR}"
|
||||
printf "enable_ai_gateway: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
|
||||
printf "install_codex: %s\n" "$${ARG_INSTALL}"
|
||||
printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}"
|
||||
echo "--------------------------------"
|
||||
|
||||
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_codex_in_path() {
|
||||
local CODEX_BIN=""
|
||||
if command -v codex > /dev/null 2>&1; then
|
||||
CODEX_BIN=$(command -v codex)
|
||||
elif [ -x "$HOME/.npm-global/bin/codex" ]; then
|
||||
CODEX_BIN="$HOME/.npm-global/bin/codex"
|
||||
fi
|
||||
|
||||
if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then
|
||||
echo "Warning: Could not find codex binary after install"
|
||||
return
|
||||
fi
|
||||
|
||||
local CODEX_DIR
|
||||
CODEX_DIR=$(dirname "$${CODEX_BIN}")
|
||||
|
||||
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/codex" ]; then
|
||||
ln -s "$${CODEX_BIN}" "$${CODER_SCRIPT_BIN_DIR}/codex"
|
||||
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/codex -> $${CODEX_BIN}"
|
||||
fi
|
||||
|
||||
add_path_to_shell_profiles "$${CODEX_DIR}"
|
||||
}
|
||||
|
||||
function install_codex() {
|
||||
if [ "$${ARG_INSTALL}" != "true" ]; then
|
||||
echo "Skipping Codex installation as per configuration."
|
||||
ensure_codex_in_path
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
# Detect a package manager for global installs.
|
||||
if command_exists npm; then
|
||||
PKG_INSTALL="npm install -g"
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
elif command_exists pnpm; then
|
||||
PKG_INSTALL="pnpm add -g"
|
||||
elif command_exists bun; then
|
||||
PKG_INSTALL="bun add -g"
|
||||
else
|
||||
echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "$${BOLD}"
|
||||
|
||||
if [ -n "$${ARG_CODEX_VERSION}" ]; then
|
||||
$PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}"
|
||||
else
|
||||
$PKG_INSTALL "@openai/codex"
|
||||
fi
|
||||
printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)"
|
||||
ensure_codex_in_path
|
||||
}
|
||||
|
||||
function write_minimal_default_config() {
|
||||
local config_path="$1"
|
||||
local optional_config=""
|
||||
|
||||
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then
|
||||
optional_config='model_provider = "aigateway"'
|
||||
fi
|
||||
|
||||
if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then
|
||||
optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\""
|
||||
fi
|
||||
|
||||
cat << EOF > "$${config_path}"
|
||||
preferred_auth_method = "apikey"
|
||||
$${optional_config}
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$${ARG_WORKDIR}" ]; then
|
||||
cat << EOF >> "$${config_path}"
|
||||
[projects."$${ARG_WORKDIR}"]
|
||||
trust_level = "trusted"
|
||||
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_config_toml() {
|
||||
local config_path="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$${config_path}")"
|
||||
|
||||
if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then
|
||||
printf "Using provided base configuration\n"
|
||||
echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}"
|
||||
else
|
||||
printf "Using minimal default configuration\n"
|
||||
write_minimal_default_config "$${config_path}"
|
||||
fi
|
||||
|
||||
if [ -n "$${ARG_MCP}" ]; then
|
||||
printf "Adding MCP servers\n"
|
||||
echo "$${ARG_MCP}" >> "$${config_path}"
|
||||
fi
|
||||
|
||||
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then
|
||||
if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then
|
||||
printf "Adding AI Gateway configuration\n"
|
||||
echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}"
|
||||
else
|
||||
printf "AI Gateway provider already defined in config, skipping append\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_workdir() {
|
||||
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
|
||||
echo "Creating workdir: $${ARG_WORKDIR}"
|
||||
mkdir -p "$${ARG_WORKDIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
function add_auth_json() {
|
||||
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] || [ -z "$${ARG_OPENAI_API_KEY}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local auth_path="$HOME/.codex/auth.json"
|
||||
mkdir -p "$(dirname "$${auth_path}")"
|
||||
|
||||
cat << EOF > "$${auth_path}"
|
||||
{
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": "$${ARG_OPENAI_API_KEY}"
|
||||
}
|
||||
EOF
|
||||
echo "Seeded auth.json with API key"
|
||||
}
|
||||
|
||||
install_codex
|
||||
populate_config_toml
|
||||
setup_workdir
|
||||
add_auth_json
|
||||
@@ -1,229 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
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")"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
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
|
||||
|
||||
SESSION_TRACKING_FILE="$HOME/.codex-module/.codex-task-session"
|
||||
|
||||
find_session_for_directory() {
|
||||
local target_dir="$1"
|
||||
|
||||
if [ ! -f "$SESSION_TRACKING_FILE" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local session_id
|
||||
session_id=$(grep "^$target_dir|" "$SESSION_TRACKING_FILE" | cut -d'|' -f2 | head -1)
|
||||
|
||||
if [ -n "$session_id" ]; then
|
||||
echo "$session_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
store_session_mapping() {
|
||||
local dir="$1"
|
||||
local session_id="$2"
|
||||
|
||||
mkdir -p "$(dirname "$SESSION_TRACKING_FILE")"
|
||||
|
||||
if [ -f "$SESSION_TRACKING_FILE" ]; then
|
||||
grep -v "^$dir|" "$SESSION_TRACKING_FILE" > "$SESSION_TRACKING_FILE.tmp" 2> /dev/null || true
|
||||
mv "$SESSION_TRACKING_FILE.tmp" "$SESSION_TRACKING_FILE"
|
||||
fi
|
||||
|
||||
echo "$dir|$session_id" >> "$SESSION_TRACKING_FILE"
|
||||
}
|
||||
|
||||
find_recent_session_file() {
|
||||
local target_dir="$1"
|
||||
local sessions_dir="$HOME/.codex/sessions"
|
||||
|
||||
if [ ! -d "$sessions_dir" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local latest_file=""
|
||||
local latest_time=0
|
||||
|
||||
while IFS= read -r session_file; do
|
||||
local file_time
|
||||
file_time=$(stat -c %Y "$session_file" 2> /dev/null || stat -f %m "$session_file" 2> /dev/null || echo "0")
|
||||
local first_line
|
||||
first_line=$(head -n 1 "$session_file" 2> /dev/null)
|
||||
local session_cwd
|
||||
session_cwd=$(echo "$first_line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ "$session_cwd" = "$target_dir" ] && [ "$file_time" -gt "$latest_time" ]; then
|
||||
latest_file="$session_file"
|
||||
latest_time="$file_time"
|
||||
fi
|
||||
done < <(find "$sessions_dir" -type f -name "*.jsonl" 2> /dev/null)
|
||||
|
||||
if [ -n "$latest_file" ]; then
|
||||
local first_line
|
||||
first_line=$(head -n 1 "$latest_file")
|
||||
local session_id
|
||||
session_id=$(echo "$first_line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
|
||||
if [ -n "$session_id" ]; then
|
||||
echo "$session_id"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_session_file() {
|
||||
local target_dir="$1"
|
||||
local max_attempts=20
|
||||
local attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
local session_id
|
||||
session_id=$(find_recent_session_file "$target_dir" 2> /dev/null || echo "")
|
||||
if [ -n "$session_id" ]; then
|
||||
echo "$session_id"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_codex_installation() {
|
||||
if command_exists codex; then
|
||||
printf "Codex is installed\n"
|
||||
else
|
||||
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_workdir() {
|
||||
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
build_codex_args() {
|
||||
CODEX_ARGS=()
|
||||
|
||||
if [[ -n "${ARG_CODEX_MODEL}" ]]; then
|
||||
CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}")
|
||||
fi
|
||||
|
||||
if [ "$ARG_CONTINUE" = "true" ]; then
|
||||
existing_session=$(find_session_for_directory "$ARG_CODEX_START_DIRECTORY" 2> /dev/null || echo "")
|
||||
|
||||
if [ -n "$existing_session" ]; then
|
||||
printf "Found existing task session for this directory: %s\n" "$existing_session"
|
||||
printf "Resuming existing session...\n"
|
||||
CODEX_ARGS+=("resume" "$existing_session")
|
||||
else
|
||||
printf "No existing task session found for this directory\n"
|
||||
printf "Starting new task session...\n"
|
||||
|
||||
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"
|
||||
else
|
||||
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
fi
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf "Continue disabled, starting fresh session\n"
|
||||
|
||||
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.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
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
capture_session_id() {
|
||||
if [ "$ARG_CONTINUE" = "true" ] && [ -z "$existing_session" ]; then
|
||||
printf "Capturing new session ID...\n"
|
||||
new_session=$(wait_for_session_file "$ARG_CODEX_START_DIRECTORY" || echo "")
|
||||
|
||||
if [ -n "$new_session" ]; then
|
||||
store_session_mapping "$ARG_CODEX_START_DIRECTORY" "$new_session"
|
||||
printf "✓ Session tracked: %s\n" "$new_session"
|
||||
printf "This session will be automatically resumed on next restart\n"
|
||||
else
|
||||
printf "⚠ Could not capture session ID after 10s timeout\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
start_codex() {
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
|
||||
# enable_boundary=true. It points to a wrapper script that runs the command
|
||||
# through coder boundary, sandboxing only the agent process.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
|
||||
else
|
||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||
fi
|
||||
capture_session_id
|
||||
}
|
||||
|
||||
validate_codex_installation
|
||||
setup_workdir
|
||||
build_codex_args
|
||||
start_codex
|
||||
+2
-31
@@ -1,38 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Handle --version flag
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "codex version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
SESSION_ID=""
|
||||
IS_RESUME=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
resume)
|
||||
IS_RESUME=true
|
||||
SESSION_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$IS_RESUME" = false ]; then
|
||||
SESSION_ID="019a1234-5678-9abc-def0-123456789012"
|
||||
echo "Created new session: $SESSION_ID"
|
||||
else
|
||||
echo "Resuming session: $SESSION_ID"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo "$(date) - codex-mock (session: $SESSION_ID)"
|
||||
sleep 15
|
||||
done
|
||||
echo "codex invoked with: $*"
|
||||
exit 0
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
|
||||
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
@@ -105,6 +105,22 @@ module "gemini" {
|
||||
You are a helpful coding assistant. Always explain your code changes clearly.
|
||||
YOU MUST REPORT ALL TASKS TO CODER.
|
||||
EOT
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Installing Node.js via NodeSource..."
|
||||
|
||||
sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
|
||||
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
echo "Node version: $(node -v)"
|
||||
echo "npm version: $(npm -v)"
|
||||
echo "Node install complete."
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -118,7 +134,7 @@ For enterprise users who prefer Google's Vertex AI platform:
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
|
||||
@@ -148,22 +148,16 @@ locals {
|
||||
base_extensions = <<-EOT
|
||||
{
|
||||
"coder": {
|
||||
"command": "coder",
|
||||
"args": [
|
||||
"exp",
|
||||
"mcp",
|
||||
"server"
|
||||
],
|
||||
"command": "coder",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"enabled": true,
|
||||
"env": {
|
||||
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
|
||||
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
|
||||
},
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOT
|
||||
|
||||
@@ -17,6 +17,7 @@ echo "--------------------------------"
|
||||
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
|
||||
printf "install: %s\n" "$ARG_INSTALL"
|
||||
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
|
||||
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
|
||||
echo "--------------------------------"
|
||||
|
||||
set +o nounset
|
||||
@@ -140,6 +141,25 @@ function add_system_prompt_if_exists() {
|
||||
fi
|
||||
}
|
||||
|
||||
function patch_coder_mcp_command() {
|
||||
CODER_BIN=$(which coder)
|
||||
SETTINGS_PATH="$HOME/.gemini/settings.json"
|
||||
|
||||
if [ -z "$CODER_BIN" ]; then
|
||||
printf "Warning: could not find coder binary, MCP command path not patched.\n"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "Patching coder MCP command path to: %s\n" "$CODER_BIN"
|
||||
|
||||
TMP_SETTINGS=$(mktemp)
|
||||
jq --arg bin "$CODER_BIN" \
|
||||
'.mcpServers.coder.command = $bin' \
|
||||
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
printf "Patch complete.\n"
|
||||
}
|
||||
|
||||
function configure_mcp() {
|
||||
export CODER_MCP_APP_STATUS_SLUG="gemini"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
@@ -149,4 +169,5 @@ function configure_mcp() {
|
||||
install_gemini
|
||||
populate_settings_json
|
||||
add_system_prompt_if_exists
|
||||
patch_coder_mcp_command
|
||||
configure_mcp
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
display_name: Agent Firewall
|
||||
description: Configures agent-firewall for network isolation in Coder workspaces
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: true
|
||||
tags: [agent-firewall, ai, agents, firewall, boundary]
|
||||
---
|
||||
|
||||
# Agent Firewall
|
||||
|
||||
Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces.
|
||||
|
||||
This module:
|
||||
|
||||
- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source)
|
||||
- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
|
||||
- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable)
|
||||
- Provides the wrapper path, config path, and script names via outputs
|
||||
- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs
|
||||
|
||||
```tf
|
||||
module "agent-firewall" {
|
||||
source = "registry.coder.com/coder/agent-firewall/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation.
|
||||
|
||||
### With Claude Code
|
||||
|
||||
Use agent-firewall alongside the `claude-code` module to run Claude in a
|
||||
network-isolated environment.
|
||||
|
||||
#### As an automated task
|
||||
|
||||
```tf
|
||||
module "agent-firewall" {
|
||||
source = "registry.coder.com/coder/agent-firewall/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
resource "coder_script" "claude_with_agent_firewall" {
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Claude (Agent Firewall)"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
coder exp sync want claude-agent-firewall \
|
||||
${join(" ", module.agent-firewall.scripts)} \
|
||||
${join(" ", module.claude-code.scripts)}
|
||||
coder exp sync start claude-agent-firewall
|
||||
"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
#### As a Coder app
|
||||
|
||||
```tf
|
||||
module "agent-firewall" {
|
||||
source = "registry.coder.com/coder/agent-firewall/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
resource "coder_app" "claude_with_agent_firewall" {
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Claude Code"
|
||||
slug = "claude-code"
|
||||
command = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec tmux new-session -A -s claude-code \
|
||||
'"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude'
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The module ships with a comprehensive default config based on the
|
||||
[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services,
|
||||
OpenAI services, version control, package managers, container registries,
|
||||
cloud platforms, and common development tools.
|
||||
|
||||
The Coder deployment domain is automatically added to the allowlist using
|
||||
`data.coder_workspace.me.access_url`.
|
||||
|
||||
By default the config is written to
|
||||
`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can
|
||||
access the resolved path via the `agent_firewall_config_path` output. Override
|
||||
it in two ways:
|
||||
|
||||
### Inline config
|
||||
|
||||
Pass the full YAML content directly:
|
||||
|
||||
```tf
|
||||
module "agent-firewall" {
|
||||
source = "registry.coder.com/coder/agent-firewall/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
agent_firewall_config = <<-YAML
|
||||
allowlist:
|
||||
- domain=your-deployment.coder.com
|
||||
- domain=api.anthropic.com
|
||||
- domain=api.openai.com
|
||||
log_dir: /tmp/agent_firewall_logs
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
YAML
|
||||
}
|
||||
```
|
||||
|
||||
### External config file
|
||||
|
||||
Point to an existing config file in the workspace. The module will not
|
||||
write any config and the `agent_firewall_config_path` output will point to
|
||||
your path. The file must exist on disk before agent-firewall starts.
|
||||
|
||||
```tf
|
||||
module "agent-firewall" {
|
||||
source = "registry.coder.com/coder/agent-firewall/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml"
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually
|
||||
> exclusive, setting both produces a validation error.
|
||||
|
||||
See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall)
|
||||
for the full config reference.
|
||||
|
||||
## References
|
||||
|
||||
- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall)
|
||||
@@ -0,0 +1,157 @@
|
||||
# Test for agent-firewall module
|
||||
|
||||
run "plan_with_required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
}
|
||||
|
||||
# Verify the agent_firewall_wrapper_path output
|
||||
assert {
|
||||
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
|
||||
error_message = "agent_firewall_wrapper_path output should be correct"
|
||||
}
|
||||
|
||||
# Verify agent_firewall_config_path output defaults to the managed path
|
||||
assert {
|
||||
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
|
||||
error_message = "agent_firewall_config_path output should default to managed config path"
|
||||
}
|
||||
|
||||
# Verify the scripts output contains the install script name
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-install_script")
|
||||
error_message = "scripts should contain the install script name"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_compile_from_source" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
compile_agent_firewall_from_source = true
|
||||
agent_firewall_version = "main"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
|
||||
error_message = "agent_firewall_wrapper_path output should be correct"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-install_script")
|
||||
error_message = "scripts should contain the install script name"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_use_directly" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
use_agent_firewall_directly = true
|
||||
agent_firewall_version = "latest"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
|
||||
error_message = "agent_firewall_wrapper_path output should be correct"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-install_script")
|
||||
error_message = "scripts should contain the install script name"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_custom_hooks" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
pre_install_script = "echo 'Before install'"
|
||||
post_install_script = "echo 'After install'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-install_script")
|
||||
error_message = "scripts should contain the install script name"
|
||||
}
|
||||
|
||||
# Verify pre and post install script names are set
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-pre_install_script")
|
||||
error_message = "scripts should contain the pre_install script name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(output.scripts, "coder-agent-firewall-post_install_script")
|
||||
error_message = "scripts should contain the post_install script name"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_custom_module_directory" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
module_directory = "$HOME/.coder-modules/custom/agent-firewall"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh"
|
||||
error_message = "agent_firewall_wrapper_path output should use custom module directory"
|
||||
}
|
||||
|
||||
# Config path should also follow the module directory
|
||||
assert {
|
||||
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml"
|
||||
error_message = "agent_firewall_config_path output should use custom module directory"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_inline_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n"
|
||||
}
|
||||
|
||||
# Inline config should still point to the managed path.
|
||||
assert {
|
||||
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
|
||||
error_message = "agent_firewall_config_path output should point to managed config path"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_config_path" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_firewall_config_path = "/workspace/my-boundary-config.yaml"
|
||||
}
|
||||
|
||||
# agent_firewall_config_path output should point to the user-provided path.
|
||||
assert {
|
||||
condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml"
|
||||
error_message = "agent_firewall_config_path output should point to user-provided path"
|
||||
}
|
||||
}
|
||||
|
||||
run "plan_with_both_configs_should_fail" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_firewall_config = "allowlist: []"
|
||||
agent_firewall_config_path = "/workspace/config.yaml"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.agent_firewall_config,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
allowlist:
|
||||
- domain=${CODER_DOMAIN}
|
||||
|
||||
# Anthropic Services
|
||||
- domain=api.anthropic.com
|
||||
- domain=statsig.anthropic.com
|
||||
- domain=claude.ai
|
||||
|
||||
# OpenAI Services
|
||||
- domain=api.openai.com
|
||||
- domain=platform.openai.com
|
||||
- domain=openai.com
|
||||
- domain=chatgpt.com
|
||||
- domain=*.oaiusercontent.com
|
||||
- domain=*.oaistatic.com
|
||||
|
||||
# Version Control
|
||||
- domain=github.com
|
||||
- domain=www.github.com
|
||||
- domain=api.github.com
|
||||
- domain=raw.githubusercontent.com
|
||||
- domain=objects.githubusercontent.com
|
||||
- domain=codeload.github.com
|
||||
- domain=avatars.githubusercontent.com
|
||||
- domain=camo.githubusercontent.com
|
||||
- domain=gist.github.com
|
||||
- domain=gitlab.com
|
||||
- domain=www.gitlab.com
|
||||
- domain=registry.gitlab.com
|
||||
- domain=bitbucket.org
|
||||
- domain=www.bitbucket.org
|
||||
- domain=api.bitbucket.org
|
||||
|
||||
# Container Registries
|
||||
- domain=registry-1.docker.io
|
||||
- domain=auth.docker.io
|
||||
- domain=index.docker.io
|
||||
- domain=hub.docker.com
|
||||
- domain=www.docker.com
|
||||
- domain=production.cloudflare.docker.com
|
||||
- domain=download.docker.com
|
||||
- domain=*.gcr.io
|
||||
- domain=ghcr.io
|
||||
- domain=mcr.microsoft.com
|
||||
- domain=*.data.mcr.microsoft.com
|
||||
|
||||
# Cloud Platforms
|
||||
- domain=cloud.google.com
|
||||
- domain=accounts.google.com
|
||||
- domain=gcloud.google.com
|
||||
- domain=*.googleapis.com
|
||||
- domain=storage.googleapis.com
|
||||
- domain=compute.googleapis.com
|
||||
- domain=container.googleapis.com
|
||||
- domain=azure.com
|
||||
- domain=portal.azure.com
|
||||
- domain=microsoft.com
|
||||
- domain=www.microsoft.com
|
||||
- domain=*.microsoftonline.com
|
||||
- domain=packages.microsoft.com
|
||||
- domain=dotnet.microsoft.com
|
||||
- domain=dot.net
|
||||
- domain=visualstudio.com
|
||||
- domain=dev.azure.com
|
||||
- domain=oracle.com
|
||||
- domain=www.oracle.com
|
||||
- domain=java.com
|
||||
- domain=www.java.com
|
||||
- domain=java.net
|
||||
- domain=www.java.net
|
||||
- domain=download.oracle.com
|
||||
- domain=yum.oracle.com
|
||||
|
||||
# Package Managers - JavaScript/Node
|
||||
- domain=registry.npmjs.org
|
||||
- domain=www.npmjs.com
|
||||
- domain=www.npmjs.org
|
||||
- domain=npmjs.com
|
||||
- domain=npmjs.org
|
||||
- domain=yarnpkg.com
|
||||
- domain=registry.yarnpkg.com
|
||||
|
||||
# Package Managers - Python
|
||||
- domain=pypi.org
|
||||
- domain=www.pypi.org
|
||||
- domain=files.pythonhosted.org
|
||||
- domain=pythonhosted.org
|
||||
- domain=test.pypi.org
|
||||
- domain=pypi.python.org
|
||||
- domain=pypa.io
|
||||
- domain=www.pypa.io
|
||||
|
||||
# Package Managers - Ruby
|
||||
- domain=rubygems.org
|
||||
- domain=www.rubygems.org
|
||||
- domain=api.rubygems.org
|
||||
- domain=index.rubygems.org
|
||||
- domain=ruby-lang.org
|
||||
- domain=www.ruby-lang.org
|
||||
- domain=rubyforge.org
|
||||
- domain=www.rubyforge.org
|
||||
- domain=rubyonrails.org
|
||||
- domain=www.rubyonrails.org
|
||||
- domain=rvm.io
|
||||
- domain=get.rvm.io
|
||||
|
||||
# Package Managers - Rust
|
||||
- domain=crates.io
|
||||
- domain=www.crates.io
|
||||
- domain=static.crates.io
|
||||
- domain=rustup.rs
|
||||
- domain=static.rust-lang.org
|
||||
- domain=www.rust-lang.org
|
||||
|
||||
# Package Managers - Go
|
||||
- domain=proxy.golang.org
|
||||
- domain=sum.golang.org
|
||||
- domain=index.golang.org
|
||||
- domain=golang.org
|
||||
- domain=www.golang.org
|
||||
- domain=go.dev
|
||||
- domain=dl.google.com
|
||||
- domain=goproxy.io
|
||||
- domain=pkg.go.dev
|
||||
|
||||
# Package Managers - JVM
|
||||
- domain=maven.org
|
||||
- domain=repo.maven.org
|
||||
- domain=central.maven.org
|
||||
- domain=repo1.maven.org
|
||||
- domain=jcenter.bintray.com
|
||||
- domain=gradle.org
|
||||
- domain=www.gradle.org
|
||||
- domain=services.gradle.org
|
||||
- domain=spring.io
|
||||
- domain=repo.spring.io
|
||||
|
||||
# Package Managers - Other Languages
|
||||
- domain=packagist.org
|
||||
- domain=www.packagist.org
|
||||
- domain=repo.packagist.org
|
||||
- domain=nuget.org
|
||||
- domain=www.nuget.org
|
||||
- domain=api.nuget.org
|
||||
- domain=pub.dev
|
||||
- domain=api.pub.dev
|
||||
- domain=hex.pm
|
||||
- domain=www.hex.pm
|
||||
- domain=cpan.org
|
||||
- domain=www.cpan.org
|
||||
- domain=metacpan.org
|
||||
- domain=www.metacpan.org
|
||||
- domain=api.metacpan.org
|
||||
- domain=cocoapods.org
|
||||
- domain=www.cocoapods.org
|
||||
- domain=cdn.cocoapods.org
|
||||
- domain=haskell.org
|
||||
- domain=www.haskell.org
|
||||
- domain=hackage.haskell.org
|
||||
- domain=swift.org
|
||||
- domain=www.swift.org
|
||||
|
||||
# Linux Distributions
|
||||
- domain=archive.ubuntu.com
|
||||
- domain=security.ubuntu.com
|
||||
- domain=ubuntu.com
|
||||
- domain=www.ubuntu.com
|
||||
- domain=*.ubuntu.com
|
||||
- domain=ppa.launchpad.net
|
||||
- domain=launchpad.net
|
||||
- domain=www.launchpad.net
|
||||
|
||||
# Development Tools & Platforms
|
||||
- domain=dl.k8s.io
|
||||
- domain=pkgs.k8s.io
|
||||
- domain=k8s.io
|
||||
- domain=www.k8s.io
|
||||
- domain=releases.hashicorp.com
|
||||
- domain=apt.releases.hashicorp.com
|
||||
- domain=rpm.releases.hashicorp.com
|
||||
- domain=archive.releases.hashicorp.com
|
||||
- domain=hashicorp.com
|
||||
- domain=www.hashicorp.com
|
||||
- domain=repo.anaconda.com
|
||||
- domain=conda.anaconda.org
|
||||
- domain=anaconda.org
|
||||
- domain=www.anaconda.com
|
||||
- domain=anaconda.com
|
||||
- domain=continuum.io
|
||||
- domain=apache.org
|
||||
- domain=www.apache.org
|
||||
- domain=archive.apache.org
|
||||
- domain=downloads.apache.org
|
||||
- domain=eclipse.org
|
||||
- domain=www.eclipse.org
|
||||
- domain=download.eclipse.org
|
||||
- domain=nodejs.org
|
||||
- domain=www.nodejs.org
|
||||
|
||||
# Cloud Services & Monitoring
|
||||
- domain=statsig.com
|
||||
- domain=www.statsig.com
|
||||
- domain=api.statsig.com
|
||||
- domain=*.sentry.io
|
||||
|
||||
# Content Delivery & Mirrors
|
||||
- domain=*.sourceforge.net
|
||||
- domain=packagecloud.io
|
||||
- domain=*.packagecloud.io
|
||||
|
||||
# Schema & Configuration
|
||||
- domain=json-schema.org
|
||||
- domain=www.json-schema.org
|
||||
- domain=json.schemastore.org
|
||||
- domain=www.schemastore.org
|
||||
log_dir: ${BOUNDARY_LOG_DIR}
|
||||
log_level: warn
|
||||
proxy_port: 8087
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import {
|
||||
execContainer,
|
||||
readFileContainer,
|
||||
runTerraformInit,
|
||||
runTerraformApply,
|
||||
testRequiredVariables,
|
||||
runContainer,
|
||||
removeContainer,
|
||||
} from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
execModuleScript,
|
||||
extractCoderEnvVars,
|
||||
} from "../agentapi/test-util";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
moduleVariables?: Record<string, string>;
|
||||
skipCoderMock?: boolean;
|
||||
}
|
||||
|
||||
const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall";
|
||||
const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`;
|
||||
const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`;
|
||||
|
||||
const setup = async (
|
||||
props?: SetupProps,
|
||||
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
...props?.moduleVariables,
|
||||
});
|
||||
|
||||
const coderEnvVars = extractCoderEnvVars(state);
|
||||
const id = await runContainer("codercom/enterprise-node:latest");
|
||||
registerCleanup(async () => {
|
||||
await removeContainer(id);
|
||||
});
|
||||
|
||||
await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]);
|
||||
|
||||
// Create a mock coder binary with boundary subcommand and exp sync support
|
||||
if (!props?.skipCoderMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: await loadTestFile(import.meta.dir, "coder-mock.sh"),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract ALL coder_scripts from the state (coder-utils creates multiple)
|
||||
const allScripts = state.resources
|
||||
.filter((r) => r.type === "coder_script")
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
script: r.instances[0].attributes.script as string,
|
||||
}));
|
||||
|
||||
// Run scripts in lifecycle order
|
||||
const executionOrder = [
|
||||
"pre_install_script",
|
||||
"install_script",
|
||||
"post_install_script",
|
||||
];
|
||||
const orderedScripts = executionOrder
|
||||
.map((name) => allScripts.find((s) => s.name === name))
|
||||
.filter((s): s is NonNullable<typeof s> => s != null);
|
||||
|
||||
// Write each script individually and create a combined runner
|
||||
const scriptPaths: string[] = [];
|
||||
for (const s of orderedScripts) {
|
||||
const scriptPath = `/home/coder/${s.name}.sh`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: scriptPath,
|
||||
content: s.script,
|
||||
});
|
||||
scriptPaths.push(scriptPath);
|
||||
}
|
||||
|
||||
const combinedScript = [
|
||||
"#!/bin/bash",
|
||||
"set -o errexit",
|
||||
"set -o pipefail",
|
||||
...scriptPaths.map((p) => `bash "${p}"`),
|
||||
].join("\n");
|
||||
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/home/coder/script.sh",
|
||||
content: combinedScript,
|
||||
});
|
||||
|
||||
return { id, coderEnvVars };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("agent-firewall", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
});
|
||||
|
||||
test("terraform-state-basic", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
});
|
||||
|
||||
const resources = state.resources;
|
||||
|
||||
// No coder_env resources should exist
|
||||
const envResources = resources.filter((r) => r.type === "coder_env");
|
||||
expect(envResources).toHaveLength(0);
|
||||
|
||||
// Verify no env vars are exported
|
||||
const coderEnvVars = extractCoderEnvVars(state);
|
||||
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
|
||||
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
|
||||
|
||||
// Verify agent_firewall_config_path output
|
||||
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
|
||||
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
|
||||
);
|
||||
|
||||
// Verify agent_firewall_wrapper_path output
|
||||
expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe(
|
||||
"$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh",
|
||||
);
|
||||
|
||||
// Verify scripts output contains install script
|
||||
const scripts = state.outputs["scripts"]?.value as string[];
|
||||
expect(scripts).toContain("coder-agent-firewall-install_script");
|
||||
});
|
||||
|
||||
test("terraform-state-custom-module-directory", async () => {
|
||||
const customDir = "$HOME/.coder-modules/custom/agent-firewall";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
module_directory: customDir,
|
||||
});
|
||||
|
||||
// Verify output uses custom dir
|
||||
const outputs = state.outputs;
|
||||
expect(outputs["agent_firewall_wrapper_path"]?.value).toBe(
|
||||
`${customDir}/scripts/agent-firewall-wrapper.sh`,
|
||||
);
|
||||
// Config path follows module directory
|
||||
expect(outputs["agent_firewall_config_path"]?.value).toBe(
|
||||
`${customDir}/config/config.yaml`,
|
||||
);
|
||||
});
|
||||
|
||||
test("terraform-state-inline-config", async () => {
|
||||
const inlineConfig =
|
||||
"allowlist:\n - domain=example.com\nlog_level: debug\n";
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
agent_firewall_config: inlineConfig,
|
||||
});
|
||||
|
||||
// Inline config still writes to the managed path.
|
||||
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
|
||||
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
|
||||
);
|
||||
});
|
||||
|
||||
test("terraform-state-config-path", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
agent_firewall_config_path: "/workspace/my-config.yaml",
|
||||
});
|
||||
|
||||
// agent_firewall_config_path output should point to the user-provided path.
|
||||
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
|
||||
"/workspace/my-config.yaml",
|
||||
);
|
||||
});
|
||||
|
||||
test("happy-path-coder-subcommand", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
|
||||
// Verify the wrapper script was created
|
||||
const wrapperContent = await readFileContainer(id, WRAPPER_PATH);
|
||||
expect(wrapperContent).toContain("#!/usr/bin/env bash");
|
||||
expect(wrapperContent).toContain("coder-no-caps");
|
||||
expect(wrapperContent).toContain("boundary");
|
||||
|
||||
// Verify the wrapper script is executable
|
||||
const statResult = await execContainer(id, [
|
||||
"stat",
|
||||
"-c",
|
||||
"%a",
|
||||
WRAPPER_PATH,
|
||||
]);
|
||||
expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/);
|
||||
|
||||
// Verify coder-no-caps binary was created
|
||||
const coderNoCapsResult = await execContainer(id, [
|
||||
"test",
|
||||
"-f",
|
||||
`${MODULE_DIR}/scripts/coder-no-caps`,
|
||||
]);
|
||||
expect(coderNoCapsResult.exitCode).toBe(0);
|
||||
|
||||
// Verify default boundary config was written inside module directory
|
||||
const configContent = await readFileContainer(id, CONFIG_PATH);
|
||||
expect(configContent).toContain("allowlist:");
|
||||
expect(configContent).toContain("domain=api.anthropic.com");
|
||||
expect(configContent).toContain("domain=api.openai.com");
|
||||
expect(configContent).toContain("proxy_port: 8087");
|
||||
|
||||
// Verify Coder domain was auto-filled from data.coder_workspace.me
|
||||
// (the placeholder should be replaced with the actual deployment domain).
|
||||
expect(configContent).not.toContain("domain=your-deployment.coder.com");
|
||||
|
||||
// Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME).
|
||||
expect(configContent).toContain("log_dir: /home/coder/");
|
||||
expect(configContent).not.toContain("$HOME");
|
||||
|
||||
// Check install log
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/install.log`,
|
||||
);
|
||||
expect(installLog).toContain("Using coder boundary subcommand");
|
||||
expect(installLog).toContain("Boundary config written to");
|
||||
expect(installLog).toContain("boundary wrapper configured");
|
||||
});
|
||||
|
||||
test("inline-config-written", async () => {
|
||||
const customConfig =
|
||||
"allowlist:\n - domain=custom.example.com\nlog_level: info\n";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
agent_firewall_config: customConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
// Verify the inline config was written
|
||||
const configContent = await readFileContainer(id, CONFIG_PATH);
|
||||
expect(configContent).toContain("domain=custom.example.com");
|
||||
expect(configContent).toContain("log_level: info");
|
||||
});
|
||||
|
||||
test("config-path-skips-write", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
agent_firewall_config_path: "/workspace/external-config.yaml",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
// Verify NO config was written to the default path
|
||||
const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]);
|
||||
expect(checkResult.exitCode).not.toBe(0);
|
||||
|
||||
// Check install log confirms skip
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/install.log`,
|
||||
);
|
||||
expect(installLog).toContain(
|
||||
"Using external boundary config, skipping config write",
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Tests for use_agent_firewall_directly and
|
||||
// compile_agent_firewall_from_source are skipped because they require
|
||||
// network access (downloading boundary) or compilation which are too
|
||||
// slow for unit tests. These modes are tested manually.
|
||||
|
||||
test("custom-hooks", async () => {
|
||||
const preInstallMarker = "pre-install-executed";
|
||||
const postInstallMarker = "post-install-executed";
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`,
|
||||
post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
// Verify pre-install script ran
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/pre_install.log`,
|
||||
);
|
||||
expect(preInstallLog).toContain(preInstallMarker);
|
||||
|
||||
// Verify post-install script ran
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/post_install.log`,
|
||||
);
|
||||
expect(postInstallLog).toContain(postInstallMarker);
|
||||
|
||||
// Verify main install still ran
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/install.log`,
|
||||
);
|
||||
expect(installLog).toContain("boundary wrapper configured");
|
||||
});
|
||||
|
||||
test("no-env-vars", async () => {
|
||||
const { coderEnvVars } = await setup();
|
||||
|
||||
// No env vars should be exported by this module.
|
||||
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
|
||||
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("wrapper-script-execution", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
|
||||
// Try executing the wrapper script with a command
|
||||
const wrapperResult = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`${WRAPPER_PATH} echo boundary-test`,
|
||||
]);
|
||||
|
||||
// The wrapper passes the command directly to the boundary command
|
||||
expect(wrapperResult.stdout).toContain("boundary-test");
|
||||
});
|
||||
|
||||
test("installation-idempotency", async () => {
|
||||
const { id } = await setup();
|
||||
|
||||
// Run the installation twice
|
||||
await execModuleScript(id);
|
||||
const firstInstallLog = await readFileContainer(
|
||||
id,
|
||||
`${MODULE_DIR}/logs/install.log`,
|
||||
);
|
||||
|
||||
// Run again
|
||||
const secondRun = await execModuleScript(id);
|
||||
expect(secondRun.exitCode).toBe(0);
|
||||
|
||||
// Both runs should succeed
|
||||
expect(firstInstallLog).toContain("boundary wrapper configured");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
terraform {
|
||||
required_version = ">= 1.9"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "agent_firewall_version" {
|
||||
type = string
|
||||
description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_agent_firewall_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile agent-firewall from source instead of using the official install script."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_agent_firewall_directly" {
|
||||
type = bool
|
||||
description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "agent_firewall_config" {
|
||||
type = string
|
||||
description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path."
|
||||
default = null
|
||||
|
||||
validation {
|
||||
condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null)
|
||||
error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set."
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_firewall_config_path" {
|
||||
type = string
|
||||
description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing agent-firewall."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing agent-firewall."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "module_directory" {
|
||||
type = string
|
||||
description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall."
|
||||
default = "$HOME/.coder-modules/coder/agent-firewall"
|
||||
}
|
||||
|
||||
locals {
|
||||
boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh"
|
||||
|
||||
# Extract domain from the Coder access URL for the default config
|
||||
# allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com").
|
||||
coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
|
||||
|
||||
# Config handling: resolve which config content to write and where
|
||||
# agent_firewall_config_path output points to.
|
||||
default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", {
|
||||
CODER_DOMAIN = local.coder_domain
|
||||
BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs"
|
||||
})
|
||||
boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config
|
||||
boundary_config_dir = "${var.module_directory}/config"
|
||||
boundary_config_file_path = "${local.boundary_config_dir}/config.yaml"
|
||||
effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path
|
||||
write_boundary_config = var.agent_firewall_config_path == null
|
||||
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
BOUNDARY_VERSION = var.agent_firewall_version
|
||||
COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source)
|
||||
USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly)
|
||||
MODULE_DIR = var.module_directory
|
||||
BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path
|
||||
WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config)
|
||||
BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : ""
|
||||
BOUNDARY_CONFIG_DIR = local.boundary_config_dir
|
||||
BOUNDARY_CONFIG_FILE = local.boundary_config_file_path
|
||||
})
|
||||
}
|
||||
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = var.agent_id
|
||||
display_name_prefix = "Agent Firewall"
|
||||
module_directory = var.module_directory
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
install_script = local.install_script
|
||||
}
|
||||
|
||||
output "agent_firewall_wrapper_path" {
|
||||
description = "Path to the agent-firewall wrapper script."
|
||||
value = local.boundary_wrapper_path
|
||||
}
|
||||
|
||||
output "agent_firewall_config_path" {
|
||||
description = "Effective path to the agent-firewall config file."
|
||||
value = local.effective_boundary_config_path
|
||||
}
|
||||
|
||||
output "scripts" {
|
||||
description = "List of script names for coder exp sync coordination."
|
||||
value = module.coder_utils.scripts
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# Sets up boundary for network isolation in Coder workspaces.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BOUNDARY_VERSION='${BOUNDARY_VERSION}'
|
||||
COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}'
|
||||
USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}'
|
||||
MODULE_DIR="${MODULE_DIR}"
|
||||
BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}"
|
||||
WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}'
|
||||
BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g")
|
||||
BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}"
|
||||
BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}"
|
||||
|
||||
printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}"
|
||||
printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}"
|
||||
printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}"
|
||||
printf "MODULE_DIR: %s\n" "$${MODULE_DIR}"
|
||||
printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}"
|
||||
printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}"
|
||||
printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}"
|
||||
printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}"
|
||||
|
||||
validate_boundary_subcommand() {
|
||||
if ! command -v coder > /dev/null 2>&1; then
|
||||
echo "Error: 'coder' command not found. boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local output
|
||||
echo "Checking for license"
|
||||
if ! output=$(coder boundary 2>&1); then
|
||||
if echo "$${output}" | grep -qi "license is not entitled"; then
|
||||
echo "Error: your Coder deployment is not licensed for the boundary feature." >&2
|
||||
echo "$${output}" >&2
|
||||
echo "" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Install boundary binary if needed.
|
||||
# Uses one of three strategies:
|
||||
# 1. Compile from source (compile_boundary_from_source=true)
|
||||
# 2. Install from release (use_boundary_directly=true)
|
||||
# 3. Use coder boundary subcommand (default, no installation needed)
|
||||
install_boundary() {
|
||||
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then
|
||||
echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})"
|
||||
|
||||
# Remove existing boundary directory to allow re-running safely
|
||||
if [[ -d boundary ]]; then
|
||||
rm -rf boundary
|
||||
fi
|
||||
|
||||
echo "Cloning boundary repository"
|
||||
git clone https://github.com/coder/boundary.git
|
||||
cd boundary || exit 1
|
||||
git checkout "$${BOUNDARY_VERSION}"
|
||||
|
||||
make build
|
||||
|
||||
sudo cp boundary /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/boundary
|
||||
cd - || exit 1
|
||||
elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
|
||||
echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})"
|
||||
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}"
|
||||
else
|
||||
validate_boundary_subcommand
|
||||
echo "Using coder boundary subcommand (provided by Coder)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Write boundary config file if the module is responsible for it.
|
||||
write_boundary_config() {
|
||||
if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then
|
||||
echo "Using external boundary config, skipping config write."
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$${BOUNDARY_CONFIG_DIR}"
|
||||
echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}"
|
||||
echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}"
|
||||
}
|
||||
|
||||
# Set up boundary: install, write config, create wrapper script.
|
||||
setup_boundary() {
|
||||
echo "Setting up coder boundary..."
|
||||
|
||||
# Install boundary binary if needed
|
||||
install_boundary
|
||||
|
||||
# Write boundary config
|
||||
write_boundary_config
|
||||
|
||||
# Ensure the wrapper script directory exists.
|
||||
mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")"
|
||||
|
||||
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
|
||||
# Use boundary binary directly (from compilation or release installation)
|
||||
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec boundary "$@"
|
||||
WRAPPER_EOF
|
||||
else
|
||||
# Use coder boundary subcommand (default)
|
||||
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
|
||||
# This is necessary because boundary doesn't work with privileged binaries
|
||||
# (you can't launch privileged binaries inside network namespaces unless
|
||||
# you have sys_admin).
|
||||
CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps"
|
||||
if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then
|
||||
echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@"
|
||||
WRAPPER_EOF
|
||||
fi
|
||||
|
||||
chmod +x "$${BOUNDARY_WRAPPER_PATH}"
|
||||
echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}"
|
||||
}
|
||||
|
||||
setup_boundary
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mock coder command for testing boundary module
|
||||
# Handles: coder boundary [--help | <command>]
|
||||
# Handles: coder exp sync [want|start|complete] (no-op for testing)
|
||||
|
||||
# Handle exp sync commands (no-op for testing)
|
||||
if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "boundary" ]]; then
|
||||
shift
|
||||
|
||||
# Handle --help flag
|
||||
if [[ "$1" == "--help" ]]; then
|
||||
cat << 'EOF'
|
||||
boundary - Run commands in network isolation
|
||||
|
||||
Usage:
|
||||
coder boundary [flags] -- <command> [args...]
|
||||
|
||||
Examples:
|
||||
coder boundary -- curl https://example.com
|
||||
coder boundary -- npm install
|
||||
|
||||
Flags:
|
||||
-h, --help help for boundary
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Execute the remaining arguments as a command
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "Mock coder: Unknown command: $*"
|
||||
exit 1
|
||||
@@ -1,152 +1,148 @@
|
||||
---
|
||||
display_name: Claude Code
|
||||
description: Run the Claude Code agent in your workspace.
|
||||
description: Install and configure the Claude Code CLI in your workspace.
|
||||
icon: ../../../../.icons/claude.svg
|
||||
verified: true
|
||||
tags: [agent, claude-code, ai, tasks, anthropic, aibridge]
|
||||
tags: [agent, claude-code, ai, anthropic, ai-gateway]
|
||||
---
|
||||
|
||||
# Claude Code
|
||||
|
||||
Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI.
|
||||
Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) CLI in your workspace. Starting Claude is left to the caller (template command, IDE launcher, or a custom `coder_script`).
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
|
||||
> [!NOTE]
|
||||
> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
|
||||
> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). We plan to add those back in a follow-up. Keep using v4.x.x if you depend on them. See [#861](https://github.com/coder/registry/pull/861) for the full migration guide.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An **Anthropic API key** or a _Claude Session Token_ is required for tasks.
|
||||
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
|
||||
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
|
||||
Provide exactly one authentication method:
|
||||
|
||||
### Session Resumption Behavior
|
||||
- **Anthropic API key**: get one from the [Anthropic Console](https://console.anthropic.com/dashboard) and pass it as `anthropic_api_key`.
|
||||
- **Claude.ai OAuth token** (Pro, Max, or Enterprise accounts): generate one by running `claude setup-token` locally and pass it as `claude_code_oauth_token`.
|
||||
- **Coder AI Gateway** (Coder Premium, Coder >= 2.30.0): set `enable_ai_gateway = true`. The module authenticates against the gateway using the workspace owner's session token. Do not combine with `anthropic_api_key` or `claude_code_oauth_token`.
|
||||
|
||||
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
|
||||
## workdir
|
||||
|
||||
## State Persistence
|
||||
|
||||
AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning).
|
||||
|
||||
To disable:
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
# ... other config
|
||||
enable_state_persistence = false
|
||||
}
|
||||
```
|
||||
`workdir` is optional. When set, the module pre-creates the directory if it is missing and pre-accepts the Claude Code trust/onboarding prompt for it in `~/.claude.json`. Leave `workdir` unset if you only want the module to install the CLI and configure authentication; users can still open any project interactively and accept the trust dialog per project.
|
||||
|
||||
## Examples
|
||||
|
||||
### Usage with Agent Boundaries
|
||||
### Standalone mode with a launcher app
|
||||
|
||||
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.
|
||||
Authenticate Claude directly against Anthropic's API and add a `coder_app` that users can click from the workspace dashboard to open an interactive Claude session.
|
||||
|
||||
```tf
|
||||
locals {
|
||||
claude_workdir = "/home/coder/project"
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = local.claude_workdir
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
}
|
||||
|
||||
resource "coder_app" "claude" {
|
||||
agent_id = coder_agent.main.id
|
||||
slug = "claude"
|
||||
display_name = "Claude Code"
|
||||
icon = "/icon/claude.svg"
|
||||
open_in = "slim-window"
|
||||
command = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd ${local.claude_workdir}
|
||||
claude
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!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.
|
||||
> `coder_app.command` runs when the user clicks the app tile. Combine with `anthropic_api_key`, `claude_code_oauth_token`, or `enable_ai_gateway = true` on the module to pre-authenticate the CLI.
|
||||
|
||||
### Usage with AI Bridge
|
||||
### Usage with AI Gateway
|
||||
|
||||
[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
|
||||
[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management. Requires Coder >= 2.30.0.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_ai_gateway = true
|
||||
}
|
||||
```
|
||||
|
||||
When `enable_aibridge = true`, the module automatically sets:
|
||||
When `enable_ai_gateway = true`, the module 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
|
||||
- `ANTHROPIC_AUTH_TOKEN` to the workspace owner's Coder 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`.
|
||||
Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic.
|
||||
|
||||
### Usage with Tasks
|
||||
> [!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.
|
||||
|
||||
This example shows how to configure Claude Code with Coder tasks.
|
||||
### 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
|
||||
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.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
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"
|
||||
|
||||
# Optional: route through AI Bridge (Premium feature)
|
||||
# enable_aibridge = true
|
||||
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 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.
|
||||
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
# OR
|
||||
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
|
||||
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"
|
||||
claude_code_version = "2.0.62" # Pin to a specific Claude CLI version.
|
||||
|
||||
model = "sonnet"
|
||||
permission_mode = "plan"
|
||||
# Skip the module's installer and point at a pre-installed Claude binary.
|
||||
# claude_binary_path can only be customized when install_claude_code is false.
|
||||
install_claude_code = false
|
||||
claude_binary_path = "/opt/claude/bin"
|
||||
|
||||
model = "sonnet"
|
||||
|
||||
mcp = <<-EOF
|
||||
{
|
||||
@@ -166,6 +162,12 @@ module "claude-code" {
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Swap `anthropic_api_key` for `claude_code_oauth_token = "xxxxx-xxxx-xxxx"` to authenticate via a Claude.ai OAuth token instead. Pass exactly one.
|
||||
|
||||
> [!NOTE]
|
||||
> Servers configured through `mcp` or `mcp_config_remote_path` are added at Claude Code's [user scope](https://docs.claude.com/en/docs/claude-code/mcp#scope), making them available across every project the workspace owner opens. For project-local MCP servers, commit a `.mcp.json` to the project repository instead.
|
||||
|
||||
> [!NOTE]
|
||||
> Remote URLs should return a JSON body in the following format:
|
||||
>
|
||||
@@ -180,41 +182,37 @@ module "claude-code" {
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> The `Content-Type` header doesn't matter—both `text/plain` and `application/json` work fine.
|
||||
> The `Content-Type` header doesn't matter, both `text/plain` and `application/json` work fine.
|
||||
|
||||
### Standalone Mode
|
||||
### Serialize a downstream `coder_script` after the install pipeline
|
||||
|
||||
Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
The module exposes the `coder exp sync` name of each script it creates via the `scripts` output: an ordered list (`pre_install`, `install`, `post_install`) of names for scripts this module actually creates. Scripts that were not configured are absent from the list.
|
||||
|
||||
Downstream `coder_script` resources can wait for this module's install pipeline to finish using `coder exp sync want <self> <each name>`:
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
claude_code_version = "2.0.62"
|
||||
report_tasks = false
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Claude Code Subscription
|
||||
|
||||
```tf
|
||||
|
||||
variable "claude_code_oauth_token" {
|
||||
type = string
|
||||
description = "Generate one using `claude setup-token` command"
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
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"
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
resource "coder_script" "post_claude" {
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Run after Claude Code install"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
trap 'coder exp sync complete post-claude' EXIT
|
||||
coder exp sync want post-claude ${join(" ", module.claude-code.scripts)}
|
||||
coder exp sync start post-claude
|
||||
|
||||
# Your work here runs after claude-code finishes installing.
|
||||
claude --version
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -245,14 +243,12 @@ variable "aws_access_key_id" {
|
||||
type = string
|
||||
description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
variable "aws_secret_access_key" {
|
||||
type = string
|
||||
description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
resource "coder_env" "aws_access_key_id" {
|
||||
@@ -273,7 +269,6 @@ variable "aws_bearer_token_bedrock" {
|
||||
type = string
|
||||
description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
resource "coder_env" "bedrock_api_key" {
|
||||
@@ -284,7 +279,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -341,7 +336,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.9.2"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
@@ -373,28 +368,47 @@ module "claude-code" {
|
||||
> [!NOTE]
|
||||
> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai).
|
||||
|
||||
### Telemetry export (OpenTelemetry)
|
||||
|
||||
Claude Code can emit OpenTelemetry metrics and events covering token usage, tool calls, session lifecycle, and errors (see the [monitoring docs](https://docs.anthropic.com/en/docs/claude-code/monitoring-usage)). Set `telemetry.enabled = true` and point `otlp_endpoint` at your OTLP collector.
|
||||
|
||||
The module automatically tags every span and metric with `coder.workspace_id`, `coder.workspace_name`, `coder.workspace_owner`, and `coder.template_name` via `OTEL_RESOURCE_ATTRIBUTES`, so Claude Code telemetry can be joined directly against Coder's [audit logs](https://coder.com/docs/admin/security/audit-logs) and `exectrace` records on `workspace_id`.
|
||||
|
||||
```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"
|
||||
|
||||
telemetry = {
|
||||
enabled = true
|
||||
otlp_endpoint = "http://otel-collector.observability:4317"
|
||||
otlp_protocol = "grpc"
|
||||
otlp_headers = {
|
||||
authorization = "Bearer ${var.otel_token}"
|
||||
}
|
||||
resource_attributes = {
|
||||
"service.name" = "claude-code"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
|
||||
If you encounter any issues, check the log files in the `~/.coder-modules/coder/claude-code/logs` directory within your workspace for detailed information.
|
||||
|
||||
```bash
|
||||
# Installation logs
|
||||
cat ~/.claude-module/install.log
|
||||
|
||||
# Startup logs
|
||||
cat ~/.claude-module/agentapi-start.log
|
||||
cat ~/.coder-modules/coder/claude-code/logs/install.log
|
||||
|
||||
# Pre/post install script logs
|
||||
cat ~/.claude-module/pre_install.log
|
||||
cat ~/.claude-module/post_install.log
|
||||
cat ~/.coder-modules/coder/claude-code/logs/pre_install.log
|
||||
cat ~/.coder-modules/coder/claude-code/logs/post_install.log
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`.
|
||||
> The `workdir` variable is required and specifies the directory where Claude Code will run.
|
||||
|
||||
## References
|
||||
|
||||
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
|
||||
@@ -6,15 +6,72 @@ import {
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
execContainer,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
TerraformState,
|
||||
} from "~test";
|
||||
import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util";
|
||||
import path from "path";
|
||||
|
||||
// coder-utils orchestrates this module's scripts and can produce multiple
|
||||
// coder_script resources (pre_install, install, post_install). The shared
|
||||
// `setup` helper in ../agentapi/test-util.ts assumes a single coder_script
|
||||
// via findResourceInstance, so we define a local setup helper that collects
|
||||
// every coder_script in run order.
|
||||
|
||||
interface ModuleScripts {
|
||||
pre_install?: string;
|
||||
install: string;
|
||||
post_install?: string;
|
||||
}
|
||||
|
||||
// Script display_names produced by coder-utils (Claude Code prefix + suffix).
|
||||
// Order matters: scripts run sequentially in this order at agent startup.
|
||||
const SCRIPT_SUFFIXES = [
|
||||
"Pre-Install Script",
|
||||
"Install Script",
|
||||
"Post-Install Script",
|
||||
] as const;
|
||||
|
||||
const collectScripts = (state: TerraformState): ModuleScripts => {
|
||||
const byDisplayName: Record<string, string> = {};
|
||||
for (const resource of state.resources) {
|
||||
if (resource.type !== "coder_script") continue;
|
||||
for (const instance of resource.instances) {
|
||||
const attrs = instance.attributes as Record<string, unknown>;
|
||||
const displayName = attrs.display_name as string | undefined;
|
||||
const script = attrs.script as string | undefined;
|
||||
if (displayName && script) {
|
||||
byDisplayName[displayName] = script;
|
||||
}
|
||||
}
|
||||
}
|
||||
const scripts: Partial<ModuleScripts> = {};
|
||||
for (const suffix of SCRIPT_SUFFIXES) {
|
||||
const key = `Claude Code: ${suffix}`;
|
||||
if (!(key in byDisplayName)) continue;
|
||||
switch (suffix) {
|
||||
case "Pre-Install Script":
|
||||
scripts.pre_install = byDisplayName[key];
|
||||
break;
|
||||
case "Install Script":
|
||||
scripts.install = byDisplayName[key];
|
||||
break;
|
||||
case "Post-Install Script":
|
||||
scripts.post_install = byDisplayName[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!scripts.install) {
|
||||
throw new Error("install script not found in terraform state");
|
||||
}
|
||||
return scripts as ModuleScripts;
|
||||
};
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
@@ -33,37 +90,96 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipClaudeMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (
|
||||
props?: SetupProps,
|
||||
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
|
||||
): Promise<{
|
||||
id: string;
|
||||
coderEnvVars: Record<string, string>;
|
||||
scripts: ModuleScripts;
|
||||
}> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id, coderEnvVars } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_claude_code: props?.skipClaudeMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
workdir: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
const moduleDir = path.resolve(import.meta.dir);
|
||||
const state = await runTerraformApply(moduleDir, {
|
||||
agent_id: "foo",
|
||||
workdir: projectDir,
|
||||
// Default to skipping the real installer; individual tests opt in.
|
||||
install_claude_code: "false",
|
||||
...props?.moduleVariables,
|
||||
});
|
||||
const scripts = collectScripts(state);
|
||||
const coderEnvVars = extractCoderEnvVars(state);
|
||||
|
||||
const id = await runContainer("codercom/enterprise-node:latest");
|
||||
registerCleanup(async () => {
|
||||
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
|
||||
console.log(`Not removing container ${id} in debug mode`);
|
||||
return;
|
||||
}
|
||||
await removeContainer(id);
|
||||
});
|
||||
|
||||
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
|
||||
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
|
||||
// succeed without a real control plane.
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: "#!/bin/bash\nexit 0\n",
|
||||
});
|
||||
if (!props?.skipClaudeMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/claude",
|
||||
content: await loadTestFile(import.meta.dir, "claude-mock.sh"),
|
||||
content: await Bun.file(
|
||||
path.join(moduleDir, "testdata", "claude-mock.sh"),
|
||||
).text(),
|
||||
});
|
||||
}
|
||||
return { id, coderEnvVars };
|
||||
return { id, coderEnvVars, scripts };
|
||||
};
|
||||
|
||||
// Runs the coder-utils script pipeline (pre_install, install, post_install) in
|
||||
// order inside the container. Each script is written to /tmp and executed
|
||||
// under bash with the test's env vars exported first.
|
||||
const runScripts = async (
|
||||
id: string,
|
||||
scripts: ModuleScripts,
|
||||
env?: Record<string, string>,
|
||||
) => {
|
||||
const entries = env ? Object.entries(env) : [];
|
||||
const envArgs =
|
||||
entries.length > 0
|
||||
? entries
|
||||
.map(
|
||||
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
|
||||
)
|
||||
.join(" && ") + " && "
|
||||
: "";
|
||||
const ordered: [string, string | undefined][] = [
|
||||
["pre_install", scripts.pre_install],
|
||||
["install", scripts.install],
|
||||
["post_install", scripts.post_install],
|
||||
];
|
||||
for (const [name, script] of ordered) {
|
||||
if (!script) continue;
|
||||
const target = `/tmp/coder-utils-${name}.sh`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: target,
|
||||
content: script,
|
||||
});
|
||||
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(`script ${name} failed:`);
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
@@ -74,56 +190,50 @@ describe("claude-code", async () => {
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const { id, scripts } = await setup();
|
||||
await runScripts(id, scripts);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Skipping Claude Code installation");
|
||||
});
|
||||
|
||||
test("install-claude-code-version", async () => {
|
||||
const version_to_install = "1.0.40";
|
||||
const { id, coderEnvVars } = await setup({
|
||||
const version = "1.0.40";
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipClaudeMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
claude_code_version: version_to_install,
|
||||
claude_code_version: version,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, coderEnvVars);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/install.log",
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain(version);
|
||||
});
|
||||
|
||||
test("check-latest-claude-code-version-works", async () => {
|
||||
const { id, coderEnvVars } = await setup({
|
||||
skipClaudeMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, coderEnvVars);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("claude-api-key", async () => {
|
||||
test("anthropic-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
claude_api_key: apiKey,
|
||||
anthropic_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey);
|
||||
});
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"',
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CLAUDE_API_KEY");
|
||||
test("claude-code-oauth-token", async () => {
|
||||
const token = "test-oauth-token-456";
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
claude_code_oauth_token: token,
|
||||
},
|
||||
});
|
||||
expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token);
|
||||
});
|
||||
|
||||
test("claude-mcp-config", async () => {
|
||||
@@ -135,349 +245,67 @@ describe("claude-code", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { id, coderEnvVars } = await setup({
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipClaudeMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
mcp: mcpConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, coderEnvVars);
|
||||
|
||||
const resp = await readFileContainer(id, "/home/coder/.claude.json");
|
||||
expect(resp).toContain("test-cmd");
|
||||
});
|
||||
|
||||
test("claude-task-prompt", async () => {
|
||||
const prompt = "This is a task prompt for Claude.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("claude-permission-mode", async () => {
|
||||
const mode = "plan";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
permission_mode: mode,
|
||||
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(`--permission-mode ${mode}`);
|
||||
});
|
||||
|
||||
test("claude-auto-permission-mode", async () => {
|
||||
const mode = "auto";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
permission_mode: mode,
|
||||
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(`--permission-mode ${mode}`);
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
const claudeConfig = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude.json",
|
||||
);
|
||||
expect(claudeConfig).toContain("test-cmd");
|
||||
});
|
||||
|
||||
test("claude-model", async () => {
|
||||
const model = "opus";
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
ai_prompt: "test prompt",
|
||||
model,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify ANTHROPIC_MODEL env var is set via coder_env
|
||||
expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
|
||||
});
|
||||
|
||||
test("claude-continue-resume-task-session", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
report_tasks: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
|
||||
// Create a mock task session file with the hardcoded task session ID
|
||||
// Note: Claude CLI creates files without "session-" prefix when using --session-id
|
||||
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
|
||||
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
|
||||
await execContainer(id, ["mkdir", "-p", sessionDir]);
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'SESSIONEOF'
|
||||
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
|
||||
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
|
||||
SESSIONEOF`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("--resume");
|
||||
expect(startLog.stdout).toContain(taskSessionId);
|
||||
expect(startLog.stdout).toContain("Resuming task session");
|
||||
expect(startLog.stdout).toContain("--dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/pre_install.log",
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("claude-pre-install-script");
|
||||
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/post_install.log",
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("claude-post-install-script");
|
||||
});
|
||||
|
||||
test("workdir-variable", async () => {
|
||||
const workdir = "/home/coder/claude-test-folder";
|
||||
const { id } = await setup({
|
||||
skipClaudeMock: false,
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(workdir);
|
||||
});
|
||||
|
||||
test("coder-mcp-config-created", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_claude_code: "false",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
await runScripts(id, scripts);
|
||||
// install.sh.tftpl echoes ARG_WORKDIR and creates the directory if missing.
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/install.log",
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain(
|
||||
"Configuring Claude Code to report tasks via Coder MCP",
|
||||
);
|
||||
});
|
||||
|
||||
test("dangerously-skip-permissions", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
dangerously_skip_permissions: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--dangerously-skip-permissions`);
|
||||
});
|
||||
|
||||
test("subdomain-false", async () => {
|
||||
const { id } = await setup({
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
subdomain: "false",
|
||||
post_install_script: dedent`
|
||||
#!/bin/bash
|
||||
env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found"
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/post_install.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(
|
||||
"ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat",
|
||||
);
|
||||
});
|
||||
|
||||
test("partial-initialization-detection", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
report_tasks: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
|
||||
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
|
||||
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
|
||||
await execContainer(id, ["mkdir", "-p", sessionDir]);
|
||||
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`echo '{"sessionId":"${taskSessionId}"}' > ${sessionDir}/${taskSessionId}.jsonl`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
|
||||
// Should start new session, not try to resume invalid one
|
||||
expect(startLog.stdout).toContain("Starting new task session");
|
||||
expect(startLog.stdout).toContain("--session-id");
|
||||
});
|
||||
|
||||
test("standalone-first-build-no-sessions", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
report_tasks: "false",
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
|
||||
// Should start fresh, not try to continue
|
||||
expect(startLog.stdout).toContain("No sessions found");
|
||||
expect(startLog.stdout).toContain("starting fresh standalone session");
|
||||
expect(startLog.stdout).not.toContain("--continue");
|
||||
});
|
||||
|
||||
test("standalone-with-sessions-continues", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
report_tasks: "false",
|
||||
},
|
||||
});
|
||||
|
||||
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
|
||||
await execContainer(id, ["mkdir", "-p", sessionDir]);
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > ${sessionDir}/generic-123.jsonl << 'EOF'
|
||||
{"sessionId":"generic-123","message":{"content":"User session"},"timestamp":"2020-01-01T10:00:00.000Z"}
|
||||
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
|
||||
EOF`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
|
||||
// Should continue existing session
|
||||
expect(startLog.stdout).toContain("Sessions found");
|
||||
expect(startLog.stdout).toContain(
|
||||
"Continuing most recent standalone session",
|
||||
);
|
||||
expect(startLog.stdout).toContain("--continue");
|
||||
});
|
||||
|
||||
test("task-mode-ignores-manual-sessions", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
report_tasks: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
|
||||
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
|
||||
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
|
||||
await execContainer(id, ["mkdir", "-p", sessionDir]);
|
||||
|
||||
// Create task session (without "session-" prefix, as CLI does)
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > ${sessionDir}/${taskSessionId}.jsonl << 'EOF'
|
||||
{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"}
|
||||
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"}
|
||||
EOF`,
|
||||
]);
|
||||
|
||||
// Create manual session (newer)
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > ${sessionDir}/manual-456.jsonl << 'EOF'
|
||||
{"sessionId":"manual-456","message":{"content":"Manual"},"timestamp":"2020-01-02T10:00:00.000Z"}
|
||||
{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-02T10:00:05.000Z"}
|
||||
EOF`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
|
||||
// Should resume task session, not manual session
|
||||
expect(startLog.stdout).toContain("Resuming task session");
|
||||
expect(startLog.stdout).toContain(taskSessionId);
|
||||
expect(startLog.stdout).not.toContain("manual-456");
|
||||
expect(installLog).toContain(workdir);
|
||||
});
|
||||
|
||||
test("mcp-config-remote-path", async () => {
|
||||
@@ -485,43 +313,43 @@ EOF`,
|
||||
const successUrl =
|
||||
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
|
||||
|
||||
const { id, coderEnvVars } = await setup({
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
skipClaudeMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, coderEnvVars);
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/install.log",
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
|
||||
// Verify both URLs are attempted
|
||||
// Verify both URLs are attempted.
|
||||
expect(installLog).toContain(failingUrl);
|
||||
expect(installLog).toContain(successUrl);
|
||||
|
||||
// First URL should fail gracefully
|
||||
// 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
|
||||
// Second URL should succeed.
|
||||
expect(installLog).not.toContain(
|
||||
`Warning: Failed to fetch MCP configuration from '${successUrl}'`,
|
||||
);
|
||||
|
||||
// Should contain the MCP server add command from successful fetch
|
||||
// Should contain the MCP server add command from the successful fetch.
|
||||
expect(installLog).toContain(
|
||||
"Added stdio MCP server go-language-server to local config",
|
||||
"Added stdio MCP server go-language-server to user config",
|
||||
);
|
||||
expect(installLog).toContain(
|
||||
"Added stdio MCP server typescript-language-server to user config",
|
||||
);
|
||||
|
||||
expect(installLog).toContain(
|
||||
"Added stdio MCP server typescript-language-server to local config",
|
||||
);
|
||||
|
||||
// Verify the MCP config was added to claude.json
|
||||
// Verify the MCP config was added to .claude.json.
|
||||
const claudeConfig = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude.json",
|
||||
@@ -529,4 +357,165 @@ EOF`,
|
||||
expect(claudeConfig).toContain("typescript-language-server");
|
||||
expect(claudeConfig).toContain("go-language-server");
|
||||
});
|
||||
|
||||
test("standalone-mode-with-api-key", async () => {
|
||||
const apiKey = "test-api-key-standalone";
|
||||
const workdir = "/home/coder/project";
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
anthropic_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Configuring Claude Code for standalone mode");
|
||||
expect(installLog).toContain("Standalone mode configured successfully");
|
||||
|
||||
const claudeConfig = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude.json",
|
||||
);
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.autoUpdaterStatus).toBe("disabled");
|
||||
expect(parsed.hasCompletedOnboarding).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 () => {
|
||||
const token = "test-oauth-token-standalone";
|
||||
const { id, coderEnvVars, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
claude_code_oauth_token: token,
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Standalone mode configured successfully");
|
||||
expect(installLog).not.toContain("skipping onboarding bypass");
|
||||
|
||||
// Onboarding bypass flags must be present. Authentication happens via
|
||||
// the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env vars, not via
|
||||
// .claude.json.
|
||||
const claudeConfig = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude.json",
|
||||
);
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.hasCompletedOnboarding).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("standalone-mode-no-auth", async () => {
|
||||
const { id, coderEnvVars, scripts } = await setup();
|
||||
await runScripts(id, scripts, coderEnvVars);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("No authentication configured");
|
||||
expect(installLog).toContain("skipping onboarding bypass");
|
||||
|
||||
// .claude.json should not exist when no auth is configured.
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -e /home/coder/.claude.json && echo EXISTS || echo ABSENT",
|
||||
]);
|
||||
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: {
|
||||
telemetry: JSON.stringify({
|
||||
enabled: true,
|
||||
otlp_endpoint: "http://otel-collector:4317",
|
||||
otlp_protocol: "grpc",
|
||||
otlp_headers: { authorization: "Bearer test-token" },
|
||||
resource_attributes: { "service.name": "claude-code" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1");
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe(
|
||||
"http://otel-collector:4317",
|
||||
);
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("grpc");
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe(
|
||||
"authorization=Bearer test-token",
|
||||
);
|
||||
const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"];
|
||||
expect(attrs).toContain("coder.workspace_id=");
|
||||
expect(attrs).toContain("coder.workspace_name=");
|
||||
expect(attrs).toContain("coder.workspace_owner=");
|
||||
expect(attrs).toContain("coder.template_name=");
|
||||
expect(attrs).toContain("service.name=claude-code");
|
||||
});
|
||||
|
||||
test("telemetry-disabled-by-default", async () => {
|
||||
const { coderEnvVars } = await setup();
|
||||
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBeUndefined();
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBeUndefined();
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBeUndefined();
|
||||
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined();
|
||||
expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,18 +18,6 @@ data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
@@ -38,37 +26,8 @@ variable "icon" {
|
||||
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run Claude Code in."
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "web_app" {
|
||||
type = bool
|
||||
description = "Whether to create the web app for Claude Code. When false, AgentAPI still runs but no web UI app icon is shown in the Coder dashboard. This is automatically enabled when using Coder Tasks, regardless of this setting."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Claude Code"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Claude Code"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Claude Code CLI"
|
||||
description = "Optional project directory. When set, the module pre-creates it if missing and pre-accepts the Claude Code trust/onboarding prompt for it in ~/.claude.json."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
@@ -83,31 +42,6 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.11.8"
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for Claude Code."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = false
|
||||
}
|
||||
|
||||
|
||||
variable "install_claude_code" {
|
||||
type = bool
|
||||
description = "Whether to install Claude Code."
|
||||
@@ -126,9 +60,9 @@ variable "disable_autoupdater" {
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "claude_api_key" {
|
||||
variable "anthropic_api_key" {
|
||||
type = string
|
||||
description = "The API key to use for the Claude Code server."
|
||||
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
@@ -138,78 +72,25 @@ variable "model" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "resume_session_id" {
|
||||
type = string
|
||||
description = "Resume a specific session by ID."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "continue" {
|
||||
type = bool
|
||||
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "dangerously_skip_permissions" {
|
||||
type = bool
|
||||
description = "Skip the permission prompts. Use with caution. This will be set to true if using Coder Tasks"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "permission_mode" {
|
||||
type = string
|
||||
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
|
||||
default = ""
|
||||
validation {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "interaction_mode must be one of: default, acceptEdits, plan, auto, bypassPermissions."
|
||||
}
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "MCP JSON to be added to the claude code local scope"
|
||||
description = "JSON-encoded string of MCP server configurations. When set, servers are added at Claude Code's user scope so they are available across every project the workspace owner opens."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp_config_remote_path" {
|
||||
type = list(string)
|
||||
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)"
|
||||
description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON). Servers are added at Claude Code's user scope."
|
||||
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."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "disallowed_tools" {
|
||||
type = string
|
||||
description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files."
|
||||
default = ""
|
||||
|
||||
}
|
||||
|
||||
variable "claude_code_oauth_token" {
|
||||
type = string
|
||||
description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command"
|
||||
description = "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`."
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "system_prompt" {
|
||||
type = string
|
||||
description = "The system prompt to use for the Claude Code server."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "claude_md_path" {
|
||||
type = string
|
||||
description = "The path to CLAUDE.md."
|
||||
default = "$HOME/.claude/CLAUDE.md"
|
||||
}
|
||||
|
||||
variable "claude_binary_path" {
|
||||
type = string
|
||||
description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location."
|
||||
@@ -221,83 +102,61 @@ variable "claude_binary_path" {
|
||||
}
|
||||
}
|
||||
|
||||
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 "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_boundary" {
|
||||
variable "enable_ai_gateway" {
|
||||
type = bool
|
||||
description = "Whether to enable coder boundary for network filtering"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "compile_boundary_from_source" {
|
||||
type = bool
|
||||
description = "Whether to compile boundary from source instead of using the official install script"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "use_boundary_directly" {
|
||||
type = bool
|
||||
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_aibridge" {
|
||||
type = bool
|
||||
description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge"
|
||||
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
|
||||
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."
|
||||
condition = !(var.enable_ai_gateway && length(var.anthropic_api_key) > 0)
|
||||
error_message = "anthropic_api_key cannot be provided when enable_ai_gateway is true. AI Gateway 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."
|
||||
condition = !(var.enable_ai_gateway && length(var.claude_code_oauth_token) > 0)
|
||||
error_message = "claude_code_oauth_token cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials."
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_state_persistence" {
|
||||
type = bool
|
||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||
default = true
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_md_path" {
|
||||
count = var.claude_md_path == "" ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_CLAUDE_MD_PATH"
|
||||
value = var.claude_md_path
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_system_prompt" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
|
||||
value = local.final_system_prompt
|
||||
variable "telemetry" {
|
||||
type = object({
|
||||
enabled = optional(bool, false)
|
||||
otlp_endpoint = optional(string, "")
|
||||
otlp_protocol = optional(string, "http/protobuf")
|
||||
otlp_headers = optional(map(string), {})
|
||||
resource_attributes = optional(map(string), {})
|
||||
})
|
||||
default = {}
|
||||
description = "Configure Claude Code OpenTelemetry export. When enabled, sets CLAUDE_CODE_ENABLE_TELEMETRY and the standard OTEL_EXPORTER_OTLP_* environment variables. Coder workspace identifiers (coder.workspace_id, coder.workspace_name, coder.workspace_owner, coder.template_name) are automatically appended to OTEL_RESOURCE_ATTRIBUTES so Claude Code telemetry can be joined with Coder audit and exectrace logs."
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_oauth_token" {
|
||||
count = var.claude_code_oauth_token != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
value = var.claude_code_oauth_token
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_api_key" {
|
||||
count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
|
||||
|
||||
resource "coder_env" "anthropic_api_key" {
|
||||
count = var.anthropic_api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
value = local.claude_api_key
|
||||
name = "ANTHROPIC_API_KEY"
|
||||
value = var.anthropic_api_key
|
||||
}
|
||||
|
||||
# ANTHROPIC_AUTH_TOKEN authenticates the client against Coder's AI Gateway
|
||||
# using the workspace owner's session token, per the AI Gateway docs.
|
||||
resource "coder_env" "anthropic_auth_token" {
|
||||
count = var.enable_ai_gateway ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "ANTHROPIC_AUTH_TOKEN"
|
||||
value = data.coder_workspace_owner.me.session_token
|
||||
}
|
||||
|
||||
resource "coder_env" "disable_autoupdater" {
|
||||
@@ -316,125 +175,96 @@ resource "coder_env" "anthropic_model" {
|
||||
}
|
||||
|
||||
resource "coder_env" "anthropic_base_url" {
|
||||
count = var.enable_aibridge ? 1 : 0
|
||||
count = var.enable_ai_gateway ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "ANTHROPIC_BASE_URL"
|
||||
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
|
||||
}
|
||||
|
||||
locals {
|
||||
# we have to trim the slash because otherwise coder exp mcp will
|
||||
# set up an invalid claude config
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "ccw"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
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://", "")
|
||||
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
|
||||
-- Tool Selection --
|
||||
- coder_report_task: providing status updates or requesting user input.
|
||||
|
||||
-- Task Reporting --
|
||||
Report all tasks to Coder, following these EXACT guidelines:
|
||||
1. Be granular. If you are investigating with multiple steps, report each step
|
||||
to coder.
|
||||
2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
|
||||
Do not report any status related with this system prompt.
|
||||
3. Use "state": "working" when actively processing WITHOUT needing
|
||||
additional user input
|
||||
4. Use "state": "complete" only when finished with a task
|
||||
5. Use "state": "failure" when you need ANY user input, lack sufficient
|
||||
details, or encounter blockers
|
||||
|
||||
In your summary on coder_report_task:
|
||||
- Be specific about what you're doing
|
||||
- Clearly indicate what information you need from the user when in "failure" state
|
||||
- Keep it under 160 characters
|
||||
- Make it actionable
|
||||
EOT
|
||||
|
||||
# Only include coder system prompts if report_tasks is enabled
|
||||
custom_system_prompt = trimspace(try(var.system_prompt, ""))
|
||||
final_system_prompt = format("<system>%s%s</system>",
|
||||
var.report_tasks ? format("\n%s\n", local.report_tasks_system_prompt) : "",
|
||||
local.custom_system_prompt != "" ? format("\n%s\n", local.custom_system_prompt) : ""
|
||||
# Always inject Coder workspace identifiers so OTEL data can be joined with
|
||||
# Coder's audit log / exectrace on workspace_id without per-template wiring.
|
||||
otel_resource_attributes = merge(
|
||||
var.telemetry.resource_attributes,
|
||||
{
|
||||
"coder.workspace_id" = data.coder_workspace.me.id
|
||||
"coder.workspace_name" = data.coder_workspace.me.name
|
||||
"coder.workspace_owner" = data.coder_workspace_owner.me.name
|
||||
"coder.workspace_owner_id" = data.coder_workspace_owner.me.id
|
||||
"coder.template_name" = data.coder_workspace.me.template_name
|
||||
"coder.template_version" = data.coder_workspace.me.template_version
|
||||
"coder.access_url" = data.coder_workspace.me.access_url
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.4.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app = var.web_app
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
folder = local.workdir
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
agentapi_subdomain = var.subdomain
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
enable_state_persistence = var.enable_state_persistence
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
|
||||
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
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
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}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
resource "coder_env" "claude_code_enable_telemetry" {
|
||||
count = var.telemetry.enabled ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_CODE_ENABLE_TELEMETRY"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
output "task_app_id" {
|
||||
value = module.agentapi.task_app_id
|
||||
resource "coder_env" "otel_exporter_otlp_endpoint" {
|
||||
count = var.telemetry.enabled && var.telemetry.otlp_endpoint != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
value = var.telemetry.otlp_endpoint
|
||||
}
|
||||
|
||||
resource "coder_env" "otel_exporter_otlp_protocol" {
|
||||
count = var.telemetry.enabled ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
value = var.telemetry.otlp_protocol
|
||||
}
|
||||
|
||||
resource "coder_env" "otel_exporter_otlp_headers" {
|
||||
count = var.telemetry.enabled && length(var.telemetry.otlp_headers) > 0 ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OTEL_EXPORTER_OTLP_HEADERS"
|
||||
value = join(",", [for k, v in var.telemetry.otlp_headers : "${k}=${v}"])
|
||||
}
|
||||
|
||||
resource "coder_env" "otel_resource_attributes" {
|
||||
count = var.telemetry.enabled ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OTEL_RESOURCE_ATTRIBUTES"
|
||||
value = join(",", [for k, v in local.otel_resource_attributes : "${k}=${v}"])
|
||||
}
|
||||
|
||||
locals {
|
||||
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
ARG_CLAUDE_CODE_VERSION = var.claude_code_version
|
||||
ARG_INSTALL_CLAUDE_CODE = tostring(var.install_claude_code)
|
||||
ARG_CLAUDE_BINARY_PATH = var.claude_binary_path
|
||||
ARG_WORKDIR = local.workdir
|
||||
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"
|
||||
}
|
||||
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "0.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
module_directory = "$HOME/${local.module_dir_name}"
|
||||
display_name_prefix = "Claude Code"
|
||||
icon = var.icon
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
install_script = local.install_script
|
||||
}
|
||||
|
||||
# Pass-through of coder-utils script outputs so upstream modules can serialize
|
||||
# their coder_script resources behind this module's install pipeline using
|
||||
# `coder exp sync want <self> <each name>`.
|
||||
output "scripts" {
|
||||
description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
|
||||
value = module.coder_utils.scripts
|
||||
}
|
||||
|
||||
@@ -20,30 +20,20 @@ run "test_claude_code_basic" {
|
||||
condition = var.install_claude_code == true
|
||||
error_message = "Install claude_code should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_agentapi == true
|
||||
error_message = "Install agentapi should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.report_tasks == true
|
||||
error_message = "report_tasks should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
workdir = "/home/coder/workspace"
|
||||
claude_api_key = "test-api-key-123"
|
||||
agent_id = "test-agent-456"
|
||||
workdir = "/home/coder/workspace"
|
||||
anthropic_api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Claude API key value should match the input"
|
||||
condition = coder_env.anthropic_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Anthropic API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,30 +41,12 @@ run "test_claude_code_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
workdir = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "opus"
|
||||
ai_prompt = "Help me write better code"
|
||||
permission_mode = "plan"
|
||||
continue = true
|
||||
install_claude_code = false
|
||||
install_agentapi = false
|
||||
claude_code_version = "1.0.0"
|
||||
agentapi_version = "v0.6.0"
|
||||
dangerously_skip_permissions = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
agent_id = "test-agent-789"
|
||||
workdir = "/home/coder/custom"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "opus"
|
||||
install_claude_code = false
|
||||
claude_code_version = "1.0.0"
|
||||
}
|
||||
|
||||
assert {
|
||||
@@ -87,38 +59,13 @@ run "test_claude_code_with_custom_options" {
|
||||
error_message = "Claude model variable should be set to 'opus'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.permission_mode == "plan"
|
||||
error_message = "Permission mode should be set to 'plan'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue == true
|
||||
error_message = "Continue should be set to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.claude_code_version == "1.0.0"
|
||||
error_message = "Claude Code version should be set to '1.0.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agentapi_version == "v0.6.0"
|
||||
error_message = "AgentAPI version should be set to 'v0.6.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.dangerously_skip_permissions == true
|
||||
error_message = "dangerously_skip_permissions should be set to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_mcp_and_tools" {
|
||||
run "test_claude_code_with_mcp" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
@@ -132,24 +79,12 @@ run "test_claude_code_with_mcp_and_tools" {
|
||||
}
|
||||
}
|
||||
})
|
||||
allowed_tools = "bash,python"
|
||||
disallowed_tools = "rm"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != ""
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.allowed_tools == "bash,python"
|
||||
error_message = "Allowed tools should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.disallowed_tools == "rm"
|
||||
error_message = "Disallowed tools should be set"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_scripts" {
|
||||
@@ -173,144 +108,13 @@ run "test_claude_code_with_scripts" {
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_permission_mode_validation" {
|
||||
run "test_ai_gateway_enabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
workdir = "/home/coder/test"
|
||||
permission_mode = "acceptEdits"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "Permission mode should be one of the valid options"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_auto_permission_mode" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-auto"
|
||||
workdir = "/home/coder/test"
|
||||
permission_mode = "auto"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.permission_mode == "auto"
|
||||
error_message = "Permission mode should be set to auto"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_boundary" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-boundary"
|
||||
workdir = "/home/coder/boundary-test"
|
||||
enable_boundary = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_boundary == true
|
||||
error_message = "Boundary should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.coder_host != ""
|
||||
error_message = "Coder host should be extracted from access URL"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_system_prompt" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-system-prompt"
|
||||
workdir = "/home/coder/test"
|
||||
system_prompt = "Custom addition"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have system_prompt variable value"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_report_tasks_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-report-tasks"
|
||||
workdir = "/home/coder/test"
|
||||
# report_tasks: default is true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
# Ensure system prompt is wrapped by <system>
|
||||
assert {
|
||||
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
|
||||
error_message = "System prompt should start with <system>"
|
||||
}
|
||||
assert {
|
||||
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
|
||||
error_message = "System prompt should end with </system>"
|
||||
}
|
||||
|
||||
# Ensure Coder sections are injected when report_tasks=true (default)
|
||||
assert {
|
||||
condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have Tool Selection section"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have Task Reporting section"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_report_tasks_disabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-report-tasks"
|
||||
workdir = "/home/coder/test"
|
||||
report_tasks = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
# Ensure system prompt is wrapped by <system>
|
||||
assert {
|
||||
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
|
||||
error_message = "System prompt should start with <system>"
|
||||
}
|
||||
assert {
|
||||
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
|
||||
error_message = "System prompt should end with </system>"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_aibridge_enabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-aibridge"
|
||||
workdir = "/home/coder/aibridge"
|
||||
enable_aibridge = true
|
||||
agent_id = "test-agent-ai-gateway"
|
||||
workdir = "/home/coder/ai-gateway"
|
||||
enable_ai_gateway = true
|
||||
}
|
||||
|
||||
override_data {
|
||||
@@ -321,8 +125,8 @@ run "test_aibridge_enabled" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == true
|
||||
error_message = "AI Bridge should be enabled"
|
||||
condition = var.enable_ai_gateway == true
|
||||
error_message = "AI Gateway should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
@@ -332,102 +136,78 @@ run "test_aibridge_enabled" {
|
||||
|
||||
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"
|
||||
error_message = "ANTHROPIC_BASE_URL should point to AI Gateway endpoint"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY"
|
||||
error_message = "CLAUDE_API_KEY environment variable should be set"
|
||||
condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN"
|
||||
error_message = "ANTHROPIC_AUTH_TOKEN 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"
|
||||
condition = coder_env.anthropic_auth_token[0].value == data.coder_workspace_owner.me.session_token
|
||||
error_message = "ANTHROPIC_AUTH_TOKEN should use workspace owner's session token when ai_gateway is enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.anthropic_api_key) == 0
|
||||
error_message = "ANTHROPIC_API_KEY env should not be created when ai_gateway is enabled and no anthropic_api_key is provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_aibridge_validation_with_api_key" {
|
||||
run "test_ai_gateway_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"
|
||||
agent_id = "test-agent-validation"
|
||||
workdir = "/home/coder/test"
|
||||
enable_ai_gateway = true
|
||||
anthropic_api_key = "test-api-key"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.enable_aibridge,
|
||||
var.enable_ai_gateway,
|
||||
]
|
||||
}
|
||||
|
||||
run "test_aibridge_validation_with_oauth_token" {
|
||||
run "test_ai_gateway_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"
|
||||
enable_ai_gateway = true
|
||||
claude_code_oauth_token = "test-auth-token"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.enable_aibridge,
|
||||
var.enable_ai_gateway,
|
||||
]
|
||||
}
|
||||
|
||||
run "test_aibridge_disabled_with_api_key" {
|
||||
run "test_ai_gateway_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"
|
||||
agent_id = "test-agent-no-ai-gateway"
|
||||
workdir = "/home/coder/test"
|
||||
enable_ai_gateway = false
|
||||
anthropic_api_key = "test-api-key-xyz"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge == false
|
||||
error_message = "AI Bridge should be disabled"
|
||||
condition = var.enable_ai_gateway == false
|
||||
error_message = "AI Gateway 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"
|
||||
condition = coder_env.anthropic_api_key[0].value == "test-api-key-xyz"
|
||||
error_message = "ANTHROPIC_API_KEY should use the provided API key when ai_gateway 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_enable_state_persistence_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == true
|
||||
error_message = "enable_state_persistence should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_disable_state_persistence" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
enable_state_persistence = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_state_persistence == false
|
||||
error_message = "enable_state_persistence should be false when explicitly disabled"
|
||||
error_message = "ANTHROPIC_BASE_URL should not be set when ai_gateway is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,28 +215,115 @@ run "test_no_api_key_no_env" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-no-key"
|
||||
workdir = "/home/coder/test"
|
||||
enable_aibridge = false
|
||||
agent_id = "test-agent-no-key"
|
||||
workdir = "/home/coder/test"
|
||||
enable_ai_gateway = 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"
|
||||
condition = length(coder_env.anthropic_api_key) == 0
|
||||
error_message = "ANTHROPIC_API_KEY should not be created when no API key is provided and ai_gateway is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_api_key_count_with_aibridge_no_override" {
|
||||
run "test_api_key_count_with_ai_gateway_no_override" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-count"
|
||||
workdir = "/home/coder/test"
|
||||
enable_aibridge = true
|
||||
agent_id = "test-agent-count"
|
||||
workdir = "/home/coder/test"
|
||||
enable_ai_gateway = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.claude_api_key) == 1
|
||||
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
|
||||
condition = length(coder_env.anthropic_auth_token) == 1
|
||||
error_message = "ANTHROPIC_AUTH_TOKEN env should be created when ai_gateway is enabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run "test_script_outputs_install_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-outputs"
|
||||
workdir = "/home/coder/test"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-claude-code-install_script"
|
||||
error_message = "scripts output should list only the install script when pre/post are not configured"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_script_outputs_with_pre_and_post" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-outputs-all"
|
||||
workdir = "/home/coder/test"
|
||||
pre_install_script = "echo pre"
|
||||
post_install_script = "echo post"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts == ["coder-claude-code-pre_install_script", "coder-claude-code-install_script", "coder-claude-code-post_install_script"]
|
||||
error_message = "scripts output should list pre_install, install, post_install in run order"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_workdir_optional" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-no-workdir"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.workdir == null
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
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}
|
||||
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
|
||||
|
||||
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
|
||||
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"
|
||||
fi
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
fi
|
||||
|
||||
ensure_claude_in_path
|
||||
}
|
||||
|
||||
function setup_claude_configurations() {
|
||||
if [ ! -d "$ARG_WORKDIR" ]; then
|
||||
echo "Warning: The specified folder '$ARG_WORKDIR' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
|
||||
module_path="$HOME/.claude-module"
|
||||
mkdir -p "$module_path"
|
||||
|
||||
if [ "$ARG_MCP" != "" ]; then
|
||||
(
|
||||
cd "$ARG_WORKDIR"
|
||||
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
|
||||
|
||||
if [ -n "$ARG_ALLOWED_TOOLS" ]; then
|
||||
coder --allowedTools "$ARG_ALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_DISALLOWED_TOOLS" ]; then
|
||||
coder --disallowedTools "$ARG_DISALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
function configure_standalone_mode() {
|
||||
echo "Configuring Claude Code for standalone mode..."
|
||||
|
||||
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
|
||||
|
||||
local claude_config="$HOME/.claude.json"
|
||||
local workdir_normalized
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
|
||||
|
||||
# Create or update .claude.json with minimal configuration for API key auth
|
||||
# This skips the interactive login prompt and onboarding screens
|
||||
if [ -f "$claude_config" ]; then
|
||||
echo "Updating existing Claude configuration at $claude_config"
|
||||
|
||||
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
|
||||
'.autoUpdaterStatus = "disabled" |
|
||||
.autoModeAccepted = true |
|
||||
.bypassPermissionsModeAccepted = true |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
.hasCompletedOnboarding = true |
|
||||
.primaryApiKey = $apikey |
|
||||
.projects[$workdir].hasCompletedProjectOnboarding = true |
|
||||
.projects[$workdir].hasTrustDialogAccepted = true' \
|
||||
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
|
||||
else
|
||||
echo "Creating new Claude configuration at $claude_config"
|
||||
cat > "$claude_config" << EOF
|
||||
{
|
||||
"autoUpdaterStatus": "disabled",
|
||||
"autoModeAccepted": true,
|
||||
"bypassPermissionsModeAccepted": true,
|
||||
"hasAcknowledgedCostThreshold": true,
|
||||
"hasCompletedOnboarding": true,
|
||||
"primaryApiKey": "${CLAUDE_API_KEY:-}",
|
||||
"projects": {
|
||||
"$ARG_WORKDIR": {
|
||||
"hasCompletedProjectOnboarding": true,
|
||||
"hasTrustDialogAccepted": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Standalone mode configured successfully"
|
||||
}
|
||||
|
||||
function report_tasks() {
|
||||
if [ "$ARG_REPORT_TASKS" = "true" ]; then
|
||||
echo "Configuring Claude Code to report tasks via Coder MCP..."
|
||||
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
coder exp mcp configure claude-code "$ARG_WORKDIR"
|
||||
else
|
||||
configure_standalone_mode
|
||||
fi
|
||||
}
|
||||
|
||||
function accept_auto_mode() {
|
||||
# Pre-accept the auto mode TOS prompt so it doesn't appear interactively.
|
||||
# Claude Code shows a confirmation dialog for auto mode that blocks
|
||||
# non-interactive/headless usage.
|
||||
# Note: bypassPermissions acceptance is already handled by
|
||||
# coder exp mcp configure (task mode) and configure_standalone_mode.
|
||||
local claude_config="$HOME/.claude.json"
|
||||
|
||||
if [ -f "$claude_config" ]; then
|
||||
jq '.autoModeAccepted = true' \
|
||||
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
|
||||
else
|
||||
echo '{"autoModeAccepted": true}' > "$claude_config"
|
||||
fi
|
||||
|
||||
echo "Pre-accepted auto mode prompt"
|
||||
}
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
report_tasks
|
||||
|
||||
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then
|
||||
accept_auto_mode
|
||||
fi
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_CLAUDE_CODE_VERSION='${ARG_CLAUDE_CODE_VERSION}'
|
||||
ARG_WORKDIR='${ARG_WORKDIR}'
|
||||
ARG_INSTALL_CLAUDE_CODE='${ARG_INSTALL_CLAUDE_CODE}'
|
||||
ARG_CLAUDE_BINARY_PATH='${ARG_CLAUDE_BINARY_PATH}'
|
||||
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
|
||||
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"
|
||||
|
||||
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_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 "--------------------------------"
|
||||
|
||||
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 --scope user \"$${server_name}\" '$${server_json}' ($${source_desc})"
|
||||
claude mcp add-json --scope user "$${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
|
||||
echo "Skipping Claude Code installation as per configuration."
|
||||
ensure_claude_in_path
|
||||
return
|
||||
fi
|
||||
|
||||
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}"
|
||||
fi
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
|
||||
ensure_claude_in_path
|
||||
}
|
||||
|
||||
function setup_claude_configurations() {
|
||||
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
|
||||
echo "Warning: The specified folder '$${ARG_WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "$${ARG_WORKDIR}"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
|
||||
module_path="$HOME/.coder-modules/coder/claude-code"
|
||||
mkdir -p "$${module_path}"
|
||||
|
||||
if [ "$${ARG_MCP}" != "" ]; then
|
||||
add_mcp_servers "$${ARG_MCP}" "from module input"
|
||||
fi
|
||||
|
||||
if [ -n "$${ARG_MCP_CONFIG_REMOTE_PATH}" ] && [ "$${ARG_MCP_CONFIG_REMOTE_PATH}" != "[]" ]; then
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
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..."
|
||||
|
||||
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
|
||||
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
|
||||
return
|
||||
fi
|
||||
|
||||
local claude_config="$HOME/.claude.json"
|
||||
|
||||
if [ -f "$${claude_config}" ]; then
|
||||
echo "Updating existing Claude configuration at $${claude_config}"
|
||||
|
||||
jq '.autoUpdaterStatus = "disabled" |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
.hasCompletedOnboarding = true' \
|
||||
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
|
||||
else
|
||||
echo "Creating new Claude configuration at $${claude_config}"
|
||||
cat > "$${claude_config}" << EOF
|
||||
{
|
||||
"autoUpdaterStatus": "disabled",
|
||||
"hasAcknowledgedCostThreshold": true,
|
||||
"hasCompletedOnboarding": true
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -n "$${ARG_WORKDIR}" ]; then
|
||||
echo "Pre-accepting trust dialog for $${ARG_WORKDIR}"
|
||||
jq --arg workdir "$${ARG_WORKDIR}" \
|
||||
'.projects[$workdir].hasCompletedProjectOnboarding = true |
|
||||
.projects[$workdir].hasTrustDialogAccepted = true' \
|
||||
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
|
||||
fi
|
||||
|
||||
echo "Standalone mode configured successfully"
|
||||
}
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
write_managed_settings
|
||||
configure_standalone_mode
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
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_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-}
|
||||
ARG_CONTINUE=${ARG_CONTINUE:-false}
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
|
||||
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
|
||||
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:-"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_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"
|
||||
printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE"
|
||||
printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT"
|
||||
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
|
||||
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" = "true" ]; then
|
||||
# Install boundary by compiling from source
|
||||
echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)"
|
||||
|
||||
echo "Removing existing boundary directory to allow re-running the script safely"
|
||||
if [ -d boundary ]; then
|
||||
rm -rf boundary
|
||||
fi
|
||||
|
||||
echo "Clone boundary repository"
|
||||
git clone https://github.com/coder/boundary.git
|
||||
cd boundary
|
||||
git checkout "$ARG_BOUNDARY_VERSION"
|
||||
|
||||
# Build the binary
|
||||
make build
|
||||
|
||||
# Install binary
|
||||
sudo cp boundary /usr/local/bin/
|
||||
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
|
||||
}
|
||||
|
||||
function validate_claude_installation() {
|
||||
if command_exists claude; then
|
||||
printf "Claude Code is installed\n"
|
||||
else
|
||||
printf "Error: Claude Code is not installed. Please enable install_claude_code or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Hardcoded task session ID for Coder task reporting
|
||||
# This ensures all task sessions use a consistent, predictable ID
|
||||
TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
|
||||
|
||||
get_project_dir() {
|
||||
local workdir_normalized
|
||||
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-')
|
||||
echo "$HOME/.claude/projects/${workdir_normalized}"
|
||||
}
|
||||
|
||||
get_task_session_file() {
|
||||
echo "$(get_project_dir)/${TASK_SESSION_ID}.jsonl"
|
||||
}
|
||||
|
||||
task_session_exists() {
|
||||
local session_file
|
||||
session_file=$(get_task_session_file)
|
||||
|
||||
if [ -f "$session_file" ]; then
|
||||
printf "Task session file found: %s\n" "$session_file"
|
||||
return 0
|
||||
else
|
||||
printf "Task session file not found: %s\n" "$session_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_valid_session() {
|
||||
local session_file="$1"
|
||||
|
||||
# Check if file exists and is not empty
|
||||
# Empty files indicate the session was created but never used so they need to be removed
|
||||
if [ ! -f "$session_file" ]; then
|
||||
printf "Session validation failed: file does not exist\n"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$session_file" ]; then
|
||||
printf "Session validation failed: file is empty, removing stale file\n"
|
||||
rm -f "$session_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for minimum session content
|
||||
# Valid sessions need at least 2 lines: initial message and first response
|
||||
local line_count
|
||||
line_count=$(wc -l < "$session_file")
|
||||
if [ "$line_count" -lt 2 ]; then
|
||||
printf "Session validation failed: incomplete (only %s lines), removing incomplete file\n" "$line_count"
|
||||
rm -f "$session_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate JSONL format by checking first 3 lines
|
||||
# Claude session files use JSONL (JSON Lines) format where each line is valid JSON
|
||||
if ! head -3 "$session_file" | jq empty 2> /dev/null; then
|
||||
printf "Session validation failed: invalid JSONL format, removing corrupt file\n"
|
||||
rm -f "$session_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify the session has a valid sessionId field
|
||||
# This ensures the file structure matches Claude's session format
|
||||
if ! grep -q '"sessionId"' "$session_file" \
|
||||
|| ! grep -m 1 '"sessionId"' "$session_file" | jq -e '.sessionId' > /dev/null 2>&1; then
|
||||
printf "Session validation failed: no valid sessionId found, removing malformed file\n"
|
||||
rm -f "$session_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf "Session validation passed: %s\n" "$session_file"
|
||||
return 0
|
||||
}
|
||||
|
||||
has_any_sessions() {
|
||||
local project_dir
|
||||
project_dir=$(get_project_dir)
|
||||
|
||||
if [ -d "$project_dir" ] && find "$project_dir" -maxdepth 1 -name "*.jsonl" -size +0c 2> /dev/null | grep -q .; then
|
||||
printf "Sessions found in: %s\n" "$project_dir"
|
||||
return 0
|
||||
else
|
||||
printf "No sessions found in: %s\n" "$project_dir"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ARGS=()
|
||||
|
||||
function start_agentapi() {
|
||||
# For Task reporting
|
||||
export CODER_MCP_ALLOWED_TOOLS="coder_report_task"
|
||||
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
cd "$ARG_WORKDIR"
|
||||
|
||||
if [ -n "$ARG_PERMISSION_MODE" ]; then
|
||||
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
|
||||
echo "Resuming specified session: $ARG_RESUME_SESSION_ID"
|
||||
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
|
||||
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
|
||||
|
||||
elif [ "$ARG_CONTINUE" = "true" ]; then
|
||||
|
||||
if [ "$ARG_REPORT_TASKS" = "true" ]; then
|
||||
local session_file
|
||||
session_file=$(get_task_session_file)
|
||||
|
||||
if task_session_exists && is_valid_session "$session_file"; then
|
||||
echo "Resuming task session: $TASK_SESSION_ID"
|
||||
ARGS+=(--resume "$TASK_SESSION_ID" --dangerously-skip-permissions)
|
||||
else
|
||||
echo "Starting new task session: $TASK_SESSION_ID"
|
||||
ARGS+=(--session-id "$TASK_SESSION_ID" --dangerously-skip-permissions)
|
||||
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
else
|
||||
if has_any_sessions; then
|
||||
echo "Continuing most recent standalone session"
|
||||
ARGS+=(--continue)
|
||||
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
|
||||
else
|
||||
echo "No sessions found, starting fresh standalone session"
|
||||
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
|
||||
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
echo "Continue disabled, starting fresh session"
|
||||
[ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions)
|
||||
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then
|
||||
install_boundary
|
||||
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
|
||||
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_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \
|
||||
claude "${ARGS[@]}"
|
||||
else
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_claude_installation
|
||||
start_agentapi
|
||||
@@ -5,9 +5,6 @@ if [[ "$1" == "--version" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - claude-mock"
|
||||
sleep 15
|
||||
done
|
||||
# Mirror invocation for test assertions and exit cleanly.
|
||||
echo "claude invoked with: $*"
|
||||
exit 0
|
||||
|
||||
@@ -13,18 +13,13 @@ tags: [internal, library]
|
||||
|
||||
The Coder Utils 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 "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "1.2.0"
|
||||
version = "0.0.1"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "myagent"
|
||||
module_directory = ".my-module"
|
||||
module_directory = "$HOME/.coder-modules/coder/claude-code"
|
||||
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
@@ -70,11 +65,10 @@ By default each `coder_script` renders in the Coder UI as plain "Install Script"
|
||||
```tf
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "1.2.0"
|
||||
version = "0.0.1"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "myagent"
|
||||
module_directory = ".my-module"
|
||||
module_directory = "$HOME/.coder-modules/coder/claude-code"
|
||||
install_script = "echo installing"
|
||||
|
||||
display_name_prefix = "Claude Code" # yields "Claude Code: Install Script", etc.
|
||||
@@ -94,3 +88,14 @@ The module writes each script's stdout+stderr to `${module_directory}/logs/`:
|
||||
- `start.log`
|
||||
|
||||
Each `coder_script` `mkdir -p`s this subdirectory before its `tee` runs, so the first script to execute creates it.
|
||||
|
||||
## Script file locations
|
||||
|
||||
The module materializes each script to `${module_directory}/scripts/` before running it:
|
||||
|
||||
- `pre_install.sh`
|
||||
- `install.sh`
|
||||
- `post_install.sh`
|
||||
- `start.sh`
|
||||
|
||||
The pre-install and install `coder_script`s `mkdir -p` this subdirectory; post-install and start sync-depend on install, so the directory already exists by the time they run.
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import { describe } from "bun:test";
|
||||
import { runTerraformInit, testRequiredVariables } from "~test";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
describe("coder-utils", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
agent_name: "test-agent",
|
||||
module_directory: ".test-module",
|
||||
module_directory: "$HOME/.coder-modules/test/example",
|
||||
install_script: "echo 'install'",
|
||||
});
|
||||
|
||||
it("rejects invalid module_directory", async () => {
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-id",
|
||||
module_directory: "$HOME/.coder-modules/test",
|
||||
install_script: "echo 'install'",
|
||||
});
|
||||
} catch (ex) {
|
||||
if (!(ex instanceof Error)) {
|
||||
throw new Error("Unknown error generated");
|
||||
}
|
||||
|
||||
expect(ex.message).toContain("module_directory must match the pattern");
|
||||
expect(ex.message).toContain(
|
||||
"'$HOME/.coder-modules/<namespace>/<module-name>'",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("module_directory validation should have failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,15 +43,14 @@ variable "start_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
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_directory" {
|
||||
type = string
|
||||
description = "The module's working directory for the install/pre/post/start scripts this module writes. Logs land under a `logs/` subdirectory of this path."
|
||||
description = "The calling module's working directory. Must follow the pattern '$HOME/.coder-modules/<namespace>/<module-name>'."
|
||||
|
||||
validation {
|
||||
condition = can(regex("^\\$HOME/\\.coder-modules/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$", var.module_directory))
|
||||
error_message = "module_directory must match the pattern '$HOME/.coder-modules/<namespace>/<module-name>' (e.g. '$HOME/.coder-modules/coder/claude-code')."
|
||||
}
|
||||
}
|
||||
|
||||
variable "display_name_prefix" {
|
||||
@@ -67,29 +66,31 @@ variable "icon" {
|
||||
}
|
||||
|
||||
locals {
|
||||
path_parts = split("/", var.module_directory)
|
||||
caller_name = "${local.path_parts[length(local.path_parts) - 2]}-${local.path_parts[length(local.path_parts) - 1]}"
|
||||
|
||||
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
encoded_install_script = base64encode(var.install_script)
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
encoded_start_script = var.start_script != null ? 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"
|
||||
pre_install_script_name = "${local.caller_name}-pre_install_script"
|
||||
install_script_name = "${local.caller_name}-install_script"
|
||||
post_install_script_name = "${local.caller_name}-post_install_script"
|
||||
start_script_name = "${local.caller_name}-start_script"
|
||||
|
||||
utils_script_path = "${var.module_directory}/${var.agent_name}-utils"
|
||||
|
||||
pre_install_path = "${local.utils_script_path}/pre_install.sh"
|
||||
install_path = "${local.utils_script_path}/install.sh"
|
||||
post_install_path = "${local.utils_script_path}/post_install.sh"
|
||||
start_path = "${local.utils_script_path}/start.sh"
|
||||
pre_install_path = "${local.scripts_directory}/pre_install.sh"
|
||||
install_path = "${local.scripts_directory}/install.sh"
|
||||
post_install_path = "${local.scripts_directory}/post_install.sh"
|
||||
start_path = "${local.scripts_directory}/start.sh"
|
||||
|
||||
pre_install_log_path = "${local.log_directory}/pre_install.log"
|
||||
install_log_path = "${local.log_directory}/install.log"
|
||||
post_install_log_path = "${local.log_directory}/post_install.log"
|
||||
start_log_path = "${local.log_directory}/start.log"
|
||||
|
||||
log_directory = "${var.module_directory}/logs"
|
||||
scripts_directory = "${var.module_directory}/scripts"
|
||||
log_directory = "${var.module_directory}/logs"
|
||||
|
||||
install_sync_deps = var.pre_install_script != null ? local.pre_install_script_name : null
|
||||
|
||||
@@ -113,7 +114,8 @@ resource "coder_script" "pre_install_script" {
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.utils_script_path}
|
||||
mkdir -p ${var.module_directory}
|
||||
mkdir -p ${local.scripts_directory}
|
||||
mkdir -p ${local.log_directory}
|
||||
|
||||
trap 'coder exp sync complete ${local.pre_install_script_name}' EXIT
|
||||
@@ -136,7 +138,8 @@ resource "coder_script" "install_script" {
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p ${local.utils_script_path}
|
||||
mkdir -p ${var.module_directory}
|
||||
mkdir -p ${local.scripts_directory}
|
||||
mkdir -p ${local.log_directory}
|
||||
|
||||
trap 'coder exp sync complete ${local.install_script_name}' EXIT
|
||||
|
||||
@@ -6,8 +6,7 @@ run "test_with_all_scripts" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
post_install_script = "echo 'post-install'"
|
||||
@@ -53,7 +52,7 @@ run "test_with_all_scripts" {
|
||||
|
||||
# install should sync-want pre_install
|
||||
assert {
|
||||
condition = can(regex("sync want test-agent-install_script test-agent-pre_install_script", coder_script.install_script.script))
|
||||
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script))
|
||||
error_message = "Install script should sync-want pre_install_script when pre_install is provided"
|
||||
}
|
||||
|
||||
@@ -100,14 +99,27 @@ run "test_with_all_scripts" {
|
||||
}
|
||||
}
|
||||
|
||||
run "test_invalid_module_directory" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
module_directory = "$HOME/.coder-modules/test"
|
||||
install_script = "echo 'install'"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.module_directory,
|
||||
]
|
||||
}
|
||||
|
||||
# Test with only install_script (minimum required input)
|
||||
run "test_install_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo 'install'"
|
||||
}
|
||||
|
||||
@@ -140,8 +152,7 @@ run "test_install_and_start" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
@@ -173,7 +184,7 @@ run "test_install_and_start" {
|
||||
|
||||
# start should sync-want install (no post_install)
|
||||
assert {
|
||||
condition = can(regex("sync want test-agent-start_script test-agent-install_script", coder_script.start_script[0].script))
|
||||
condition = can(regex("sync want test-example-start_script test-example-install_script", coder_script.start_script[0].script))
|
||||
error_message = "Start script should sync-want install_script"
|
||||
}
|
||||
}
|
||||
@@ -184,8 +195,7 @@ run "test_with_mock_data" {
|
||||
|
||||
variables {
|
||||
agent_id = "mock-agent"
|
||||
agent_name = "mock-agent"
|
||||
module_directory = ".mock-module"
|
||||
module_directory = "$HOME/.coder-modules/test/mock"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
@@ -233,26 +243,25 @@ run "test_with_mock_data" {
|
||||
}
|
||||
}
|
||||
|
||||
# Test script naming with custom agent_name
|
||||
run "test_script_naming" {
|
||||
# Test sync naming derived from module_directory
|
||||
run "test_script_naming_from_module_directory" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
agent_name = "custom-name"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/custom/name"
|
||||
install_script = "echo 'install'"
|
||||
start_script = "echo 'start'"
|
||||
}
|
||||
|
||||
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"
|
||||
error_message = "Install script should derive sync names from module_directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("custom-name-start_script", coder_script.start_script[0].script))
|
||||
error_message = "Start script should use custom agent_name in sync commands"
|
||||
error_message = "Start script should derive sync names from module_directory"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,8 +271,7 @@ run "test_install_syncs_with_pre_install" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
}
|
||||
@@ -274,7 +282,7 @@ run "test_install_syncs_with_pre_install" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("sync want test-agent-install_script test-agent-pre_install_script", coder_script.install_script.script))
|
||||
condition = can(regex("sync want test-example-install_script test-example-pre_install_script", coder_script.install_script.script))
|
||||
error_message = "Install script should sync-want pre_install_script"
|
||||
}
|
||||
}
|
||||
@@ -285,8 +293,7 @@ run "test_start_syncs_with_post_install" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo 'install'"
|
||||
post_install_script = "echo 'post-install'"
|
||||
start_script = "echo 'start'"
|
||||
@@ -294,13 +301,13 @@ run "test_start_syncs_with_post_install" {
|
||||
|
||||
# start should sync-want both install and post_install
|
||||
assert {
|
||||
condition = can(regex("sync want test-agent-start_script test-agent-install_script test-agent-post_install_script", coder_script.start_script[0].script))
|
||||
condition = can(regex("sync want test-example-start_script test-example-install_script test-example-post_install_script", coder_script.start_script[0].script))
|
||||
error_message = "Start script should sync-want both install_script and post_install_script"
|
||||
}
|
||||
|
||||
# post_install should sync-want install
|
||||
assert {
|
||||
condition = can(regex("sync want test-agent-post_install_script test-agent-install_script", coder_script.post_install_script[0].script))
|
||||
condition = can(regex("sync want test-example-post_install_script test-example-install_script", coder_script.post_install_script[0].script))
|
||||
error_message = "Post-install script should sync-want install_script"
|
||||
}
|
||||
}
|
||||
@@ -311,8 +318,7 @@ run "test_display_name_prefix_applied" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
display_name_prefix = "Claude Code"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
@@ -347,8 +353,7 @@ run "test_icon_applied" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
icon = "/icon/claude.svg"
|
||||
pre_install_script = "echo 'pre-install'"
|
||||
install_script = "echo 'install'"
|
||||
@@ -383,8 +388,7 @@ run "test_optional_scripts_absent_by_default" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo install"
|
||||
}
|
||||
|
||||
@@ -410,8 +414,7 @@ run "test_scripts_output_with_all" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo pre"
|
||||
install_script = "echo install"
|
||||
post_install_script = "echo post"
|
||||
@@ -424,22 +427,22 @@ run "test_scripts_output_with_all" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[0] == "test-agent-pre_install_script"
|
||||
condition = output.scripts[0] == "test-example-pre_install_script"
|
||||
error_message = "scripts[0] must be the pre-install name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[1] == "test-agent-install_script"
|
||||
condition = output.scripts[1] == "test-example-install_script"
|
||||
error_message = "scripts[1] must be the install name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[2] == "test-agent-post_install_script"
|
||||
condition = output.scripts[2] == "test-example-post_install_script"
|
||||
error_message = "scripts[2] must be the post-install name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[3] == "test-agent-start_script"
|
||||
condition = output.scripts[3] == "test-example-start_script"
|
||||
error_message = "scripts[3] must be the start name"
|
||||
}
|
||||
}
|
||||
@@ -449,8 +452,7 @@ run "test_scripts_output_with_install_only" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo install"
|
||||
}
|
||||
|
||||
@@ -460,7 +462,7 @@ run "test_scripts_output_with_install_only" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[0] == "test-agent-install_script"
|
||||
condition = output.scripts[0] == "test-example-install_script"
|
||||
error_message = "scripts[0] must be the install name"
|
||||
}
|
||||
}
|
||||
@@ -470,8 +472,7 @@ run "test_scripts_output_with_install_and_post" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
install_script = "echo install"
|
||||
post_install_script = "echo post"
|
||||
}
|
||||
@@ -482,12 +483,12 @@ run "test_scripts_output_with_install_and_post" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[0] == "test-agent-install_script"
|
||||
condition = output.scripts[0] == "test-example-install_script"
|
||||
error_message = "scripts[0] must be the install name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.scripts[1] == "test-agent-post_install_script"
|
||||
condition = output.scripts[1] == "test-example-post_install_script"
|
||||
error_message = "scripts[1] must be the post-install name"
|
||||
}
|
||||
}
|
||||
@@ -501,8 +502,7 @@ run "test_scripts_tee_stdout_and_log_file" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo pre"
|
||||
install_script = "echo install"
|
||||
post_install_script = "echo post"
|
||||
@@ -510,22 +510,22 @@ run "test_scripts_tee_stdout_and_log_file" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("pre_install.sh 2>&1 \\| tee .*logs/pre_install.log", coder_script.pre_install_script[0].script))
|
||||
condition = can(regex("pre_install\\.sh 2>&1 \\| tee .*logs/pre_install\\.log", coder_script.pre_install_script[0].script))
|
||||
error_message = "pre_install wrapper must tee combined output to the logs/ subdirectory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("install.sh 2>&1 \\| tee .*logs/install.log", coder_script.install_script.script))
|
||||
condition = can(regex("install\\.sh 2>&1 \\| tee .*logs/install\\.log", coder_script.install_script.script))
|
||||
error_message = "install wrapper must tee combined output to the logs/ subdirectory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("post_install.sh 2>&1 \\| tee .*logs/post_install.log", coder_script.post_install_script[0].script))
|
||||
condition = can(regex("post_install\\.sh 2>&1 \\| tee .*logs/post_install\\.log", coder_script.post_install_script[0].script))
|
||||
error_message = "post_install wrapper must tee combined output to the logs/ subdirectory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("start.sh 2>&1 \\| tee .*logs/start.log", coder_script.start_script[0].script))
|
||||
condition = can(regex("start\\.sh 2>&1 \\| tee .*logs/start\\.log", coder_script.start_script[0].script))
|
||||
error_message = "start wrapper must tee combined output to the logs/ subdirectory"
|
||||
}
|
||||
}
|
||||
@@ -537,8 +537,7 @@ run "test_logs_nested_under_module_directory" {
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo pre"
|
||||
install_script = "echo install"
|
||||
post_install_script = "echo post"
|
||||
@@ -546,22 +545,22 @@ run "test_logs_nested_under_module_directory" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("tee .test-module/logs/pre_install.log", coder_script.pre_install_script[0].script))
|
||||
condition = strcontains(coder_script.pre_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/pre_install.log")
|
||||
error_message = "pre_install log must land under module_directory/logs"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("tee .test-module/logs/install.log", coder_script.install_script.script))
|
||||
condition = strcontains(coder_script.install_script.script, "tee $HOME/.coder-modules/test/example/logs/install.log")
|
||||
error_message = "install log must land under module_directory/logs"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("tee .test-module/logs/post_install.log", coder_script.post_install_script[0].script))
|
||||
condition = strcontains(coder_script.post_install_script[0].script, "tee $HOME/.coder-modules/test/example/logs/post_install.log")
|
||||
error_message = "post_install log must land under module_directory/logs"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("tee .test-module/logs/start.log", coder_script.start_script[0].script))
|
||||
condition = strcontains(coder_script.start_script[0].script, "tee $HOME/.coder-modules/test/example/logs/start.log")
|
||||
error_message = "start log must land under module_directory/logs"
|
||||
}
|
||||
|
||||
@@ -569,104 +568,61 @@ run "test_logs_nested_under_module_directory" {
|
||||
# and start sync-depend on install so the directory already exists by
|
||||
# the time they run.
|
||||
assert {
|
||||
condition = can(regex("mkdir -p .test-module/logs", coder_script.pre_install_script[0].script))
|
||||
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/logs")
|
||||
error_message = "pre_install script must mkdir -p the logs/ sub-path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("mkdir -p .test-module/logs", coder_script.install_script.script))
|
||||
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/logs")
|
||||
error_message = "install script must mkdir -p the logs/ sub-path"
|
||||
}
|
||||
}
|
||||
|
||||
# Scripts are written to ${module_directory}/${agent_name}-utils/ so the
|
||||
# wrapper must mkdir that directory and all script paths must nest under it.
|
||||
run "test_scripts_nested_under_utils_directory" {
|
||||
# Scripts unconditionally land under ${module_directory}/scripts/. Each
|
||||
# script that materializes its own `.sh` file mkdirs that path first; the
|
||||
# first script to execute creates it for the rest.
|
||||
run "test_scripts_nested_under_module_directory" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "test-agent"
|
||||
module_directory = ".test-module"
|
||||
module_directory = "$HOME/.coder-modules/test/example"
|
||||
pre_install_script = "echo pre"
|
||||
install_script = "echo install"
|
||||
post_install_script = "echo post"
|
||||
start_script = "echo start"
|
||||
}
|
||||
|
||||
# pre_install and install create the utils directory.
|
||||
assert {
|
||||
condition = can(regex("mkdir -p .test-module/test-agent-utils", coder_script.pre_install_script[0].script))
|
||||
error_message = "pre_install script must mkdir -p the utils directory"
|
||||
condition = strcontains(coder_script.pre_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/pre_install.sh")
|
||||
error_message = "pre_install script must be written under module_directory/scripts"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("mkdir -p .test-module/test-agent-utils", coder_script.install_script.script))
|
||||
error_message = "install script must mkdir -p the utils directory"
|
||||
}
|
||||
|
||||
# Every script writes its file under the utils directory.
|
||||
assert {
|
||||
condition = can(regex("base64 -d > .test-module/test-agent-utils/pre_install.sh", coder_script.pre_install_script[0].script))
|
||||
error_message = "pre_install script must write to the utils directory"
|
||||
condition = strcontains(coder_script.install_script.script, "> $HOME/.coder-modules/test/example/scripts/install.sh")
|
||||
error_message = "install script must be written under module_directory/scripts"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("base64 -d > .test-module/test-agent-utils/install.sh", coder_script.install_script.script))
|
||||
error_message = "install script must write to the utils directory"
|
||||
condition = strcontains(coder_script.post_install_script[0].script, "> $HOME/.coder-modules/test/example/scripts/post_install.sh")
|
||||
error_message = "post_install script must be written under module_directory/scripts"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("base64 -d > .test-module/test-agent-utils/post_install.sh", coder_script.post_install_script[0].script))
|
||||
error_message = "post_install script must write to the utils directory"
|
||||
condition = strcontains(coder_script.start_script[0].script, "> $HOME/.coder-modules/test/example/scripts/start.sh")
|
||||
error_message = "start script must be written under module_directory/scripts"
|
||||
}
|
||||
|
||||
# Only pre_install and install mkdir the scripts/ sub-path. post_install
|
||||
# and start sync-depend on install so the directory already exists by
|
||||
# the time they run.
|
||||
assert {
|
||||
condition = strcontains(coder_script.pre_install_script[0].script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
|
||||
error_message = "pre_install script must mkdir -p the scripts/ sub-path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("base64 -d > .test-module/test-agent-utils/start.sh", coder_script.start_script[0].script))
|
||||
error_message = "start script must write to the utils directory"
|
||||
}
|
||||
|
||||
# Every script executes from the utils directory.
|
||||
assert {
|
||||
condition = can(regex(".test-module/test-agent-utils/pre_install.sh 2>&1", coder_script.pre_install_script[0].script))
|
||||
error_message = "pre_install script must execute from the utils directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex(".test-module/test-agent-utils/install.sh 2>&1", coder_script.install_script.script))
|
||||
error_message = "install script must execute from the utils directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex(".test-module/test-agent-utils/post_install.sh 2>&1", coder_script.post_install_script[0].script))
|
||||
error_message = "post_install script must execute from the utils directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex(".test-module/test-agent-utils/start.sh 2>&1", coder_script.start_script[0].script))
|
||||
error_message = "start script must execute from the utils directory"
|
||||
}
|
||||
}
|
||||
|
||||
# The utils directory name includes agent_name so multiple modules sharing
|
||||
# the same module_directory do not collide.
|
||||
run "test_utils_directory_uses_agent_name" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
agent_name = "custom-name"
|
||||
module_directory = ".test-module"
|
||||
install_script = "echo install"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("mkdir -p .test-module/custom-name-utils", coder_script.install_script.script))
|
||||
error_message = "utils directory must include agent_name"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("base64 -d > .test-module/custom-name-utils/install.sh", coder_script.install_script.script))
|
||||
error_message = "install script must be written under agent-name-specific utils directory"
|
||||
condition = strcontains(coder_script.install_script.script, "mkdir -p $HOME/.coder-modules/test/example/scripts")
|
||||
error_message = "install script must mkdir -p the scripts/ sub-path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ A file browser for your workspace.
|
||||
module "filebrowser" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/filebrowser/coder"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "filebrowser" {
|
||||
module "filebrowser" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/filebrowser/coder"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ module "filebrowser" {
|
||||
module "filebrowser" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/filebrowser/coder"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
agent_id = coder_agent.main.id
|
||||
database_path = ".config/filebrowser.db"
|
||||
}
|
||||
@@ -49,11 +49,13 @@ module "filebrowser" {
|
||||
|
||||
### Serve from the same domain (no subdomain)
|
||||
|
||||
When `subdomain = false`, you must also set `agent_name` to the name of your `coder_agent` resource. Coder serves path-based apps at `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the agent name is required to build a base URL that matches the URL the user is actually browsing. If `agent_name` is omitted in this mode, `terraform apply` will fail with an explanatory error.
|
||||
|
||||
```tf
|
||||
module "filebrowser" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/filebrowser/coder"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
subdomain = false
|
||||
|
||||
@@ -102,4 +102,19 @@ describe("filebrowser", async () => {
|
||||
|
||||
testBaseLine(output);
|
||||
}, 15000);
|
||||
|
||||
it("fails when subdomain=false and agent_name is not provided", async () => {
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
subdomain: false,
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect(caught!.message).toContain("agent_name");
|
||||
expect(caught!.message).toContain("subdomain");
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "agent_name" {
|
||||
type = string
|
||||
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
|
||||
description = "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves."
|
||||
default = null
|
||||
}
|
||||
|
||||
@@ -102,6 +102,13 @@ resource "coder_script" "filebrowser" {
|
||||
SERVER_BASE_PATH : local.server_base_path
|
||||
})
|
||||
run_on_start = true
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = var.subdomain || var.agent_name != null
|
||||
error_message = "`agent_name` is required when `subdomain` is `false`. Coder always builds path-based app URLs as `/@<owner>/<workspace>.<agent>/apps/<slug>/`, so the filebrowser base URL must include the agent name to match. Set `agent_name = \"<your coder_agent name>\"` (e.g. `\"main\"`)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_app" "filebrowser" {
|
||||
|
||||
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.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 = "1.2.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
folder_name = "coder-dev"
|
||||
@@ -185,21 +185,55 @@ module "git-clone" {
|
||||
}
|
||||
```
|
||||
|
||||
## Git shallow clone
|
||||
## Extra `git clone` arguments
|
||||
|
||||
Limit the clone history to speed-up workspace startup by setting `depth`.
|
||||
> [!NOTE]
|
||||
> **Upgrading from v1.x?** The `depth` variable was removed in v2.0.0. Use `extra_args = ["--depth=1"]` instead.
|
||||
> Do not pass `-b` or `--branch` in `extra_args` when `branch_name` is
|
||||
> already set (or extracted from the URL). Git silently accepts the last
|
||||
> `-b` flag, so the two values would conflict.
|
||||
|
||||
When `depth` is greater than `0` the module runs `git clone --depth <depth>`.
|
||||
If not defined, the default, `0`, performs a full clone.
|
||||
Pass any additional flags through `extra_args` (one element per argument).
|
||||
This lets you enable anything `git clone` supports without the module having
|
||||
to expose it explicitly, for example a shallow clone, submodules, parallel
|
||||
fetches, or partial clones.
|
||||
|
||||
```tf
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.2.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
depth = 1
|
||||
extra_args = [
|
||||
"--depth=1",
|
||||
"--recurse-submodules",
|
||||
"--jobs=8",
|
||||
"--filter=blob:none",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-clone script
|
||||
|
||||
Run a custom script before cloning the repository by setting the `pre_clone_script` variable.
|
||||
This is useful for preparing the environment or validating prerequisites before cloning.
|
||||
|
||||
```tf
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
pre_clone_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Preparing to clone repository..."
|
||||
# Check prerequisites
|
||||
command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; }
|
||||
# Set up environment
|
||||
export NODE_ENV=development
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -212,7 +246,7 @@ This is useful for running initialization tasks like installing dependencies or
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.2.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
post_clone_script = <<-EOT
|
||||
@@ -225,3 +259,14 @@ module "git-clone" {
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Clone output is logged to `~/.coder-modules/coder/git-clone/<instance>/logs/clone.log`:
|
||||
|
||||
cat ~/.coder-modules/coder/git-clone/*/logs/clone.log
|
||||
|
||||
Pre-clone and post-clone script output is logged alongside:
|
||||
|
||||
cat ~/.coder-modules/coder/git-clone/*/logs/pre_clone.log
|
||||
cat ~/.coder-modules/coder/git-clone/*/logs/post_clone.log
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
executeScriptInContainer,
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
type scriptOutput,
|
||||
type TerraformState,
|
||||
} from "~test";
|
||||
|
||||
const executeScriptInContainer = async (
|
||||
state: TerraformState,
|
||||
image: string,
|
||||
before?: string,
|
||||
): Promise<scriptOutput> => {
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer(image);
|
||||
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
|
||||
if (before) {
|
||||
await execContainer(id, ["sh", "-c", before]);
|
||||
}
|
||||
const resp = await execContainer(id, ["bash", "-c", instance.script]);
|
||||
return {
|
||||
exitCode: resp.exitCode,
|
||||
stdout: resp.stdout.trim().split("\n"),
|
||||
stderr: resp.stderr.trim().split("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
// Drops a fake `git` onto PATH that prints each argv entry on its own line.
|
||||
// Lets tests prove that arguments (including ones with embedded spaces) reach
|
||||
// `git clone` as single argv tokens, which the echo line cannot show because
|
||||
// it joins with spaces.
|
||||
const installFakeGit = [
|
||||
"cat > /usr/local/bin/git <<'SHIM'",
|
||||
"#!/bin/sh",
|
||||
'for arg in "$@"; do',
|
||||
' printf "argv:%s\\n" "$arg"',
|
||||
"done",
|
||||
"SHIM",
|
||||
"chmod +x /usr/local/bin/git",
|
||||
].join("\n");
|
||||
|
||||
describe("git-clone", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
@@ -30,12 +67,11 @@ describe("git-clone", async () => {
|
||||
url: "fake-url",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/fake-url...",
|
||||
"Cloning fake-url to ~/fake-url...",
|
||||
]);
|
||||
expect(output.stderr.join(" ")).toContain("fatal");
|
||||
expect(output.stderr.join(" ")).toContain("fake-url");
|
||||
expect(output.stdout).toContain("Creating directory /root/fake-url...");
|
||||
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
expect(output.exitCode).not.toBe(0);
|
||||
expect(output.stdout.join(" ")).toContain("fatal");
|
||||
expect(output.stdout.join(" ")).toContain("fake-url");
|
||||
});
|
||||
|
||||
it("repo_dir should match repo name for https", async () => {
|
||||
@@ -206,10 +242,12 @@ describe("git-clone", async () => {
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs with gitlab clone with switch to feat/branch", async () => {
|
||||
@@ -219,10 +257,12 @@ describe("git-clone", async () => {
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://gitlab.com/mike.brew/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs with github clone with branch_name set to feat/branch", async () => {
|
||||
@@ -240,25 +280,166 @@ describe("git-clone", async () => {
|
||||
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/repo-tests.log...",
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
expect(output.stdout).toContain(
|
||||
"Creating directory /root/repo-tests.log...",
|
||||
);
|
||||
expect(output.stdout).toContain(
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to /root/repo-tests.log on branch feat/branch...",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs post-clone script", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
base_dir: "/tmp",
|
||||
post_clone_script: "echo 'Post-clone script executed'",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
"sh",
|
||||
"mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
|
||||
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
|
||||
);
|
||||
expect(output.stdout).toContain("Running post-clone script...");
|
||||
expect(output.stdout).toContain("Post-clone script executed");
|
||||
});
|
||||
|
||||
it("runs pre-clone script", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
pre_clone_script: "echo 'Pre-clone script executed'",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.stdout).toContain("Running pre-clone script...");
|
||||
expect(output.stdout).toContain("Pre-clone script executed");
|
||||
expect(output.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
});
|
||||
|
||||
it("fails when pre-clone script fails", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
pre_clone_script: "echo 'Pre-clone script failed'; exit 42",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(42);
|
||||
expect(output.stdout).toContain("Running pre-clone script...");
|
||||
expect(output.stdout).toContain("Pre-clone script failed");
|
||||
expect(output.stdout).not.toContain(
|
||||
"Cloning fake-url to /root/fake-url...",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults extra_args to empty", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
// With no extra_args the only argv tokens should be clone, url, path.
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
["argv:clone", "argv:fake-url", "argv:/root/fake-url"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes extra_args to git clone", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
extra_args: JSON.stringify([
|
||||
"--recurse-submodules",
|
||||
"--jobs=8",
|
||||
"--config=user.name=Coder User",
|
||||
"-c",
|
||||
"core.sshCommand=ssh -i /tmp/key",
|
||||
]),
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
[
|
||||
"argv:clone",
|
||||
"argv:--recurse-submodules",
|
||||
"argv:--jobs=8",
|
||||
"argv:--config=user.name=Coder User",
|
||||
"argv:-c",
|
||||
"argv:core.sshCommand=ssh -i /tmp/key",
|
||||
"argv:fake-url",
|
||||
"argv:/root/fake-url",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes extra_args alongside branch_name in the correct order", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
branch_name: "feat/branch",
|
||||
extra_args: JSON.stringify([
|
||||
"--recurse-submodules",
|
||||
"--config=user.name=Coder User",
|
||||
]),
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
installFakeGit,
|
||||
);
|
||||
expect(output.exitCode).toBe(0);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
[
|
||||
"argv:clone",
|
||||
"argv:--recurse-submodules",
|
||||
"argv:--config=user.name=Coder User",
|
||||
"argv:-b",
|
||||
"argv:feat/branch",
|
||||
"argv:fake-url",
|
||||
"argv:/root/fake-url",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes output to logs/clone.log under module directory", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
});
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await runContainer("alpine/git");
|
||||
await execContainer(id, ["sh", "-c", "apk add --no-cache bash >/dev/null"]);
|
||||
await execContainer(id, ["bash", "-c", instance.script]);
|
||||
const log = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /root/.coder-modules/coder/git-clone/*/logs/clone.log",
|
||||
]);
|
||||
expect(log.exitCode).toBe(0);
|
||||
expect(log.stdout).toContain("Cloning fake-url to /root/fake-url...");
|
||||
});
|
||||
|
||||
it("fails when post-clone script fails", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
base_dir: "/tmp",
|
||||
post_clone_script: "echo 'Post-clone script failed'; exit 43",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
|
||||
);
|
||||
expect(output.exitCode).toBe(43);
|
||||
expect(output.stdout).toContain("Running post-clone script...");
|
||||
expect(output.stdout).toContain("Post-clone script failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,10 @@ variable "folder_name" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "depth" {
|
||||
description = "If > 0, perform a shallow clone using this depth."
|
||||
type = number
|
||||
default = 0
|
||||
variable "extra_args" {
|
||||
description = "Extra arguments to pass to `git clone`, one element per argument (e.g. `[\"--recurse-submodules\", \"--jobs=8\", \"--filter=blob:none\"]`)."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "post_clone_script" {
|
||||
@@ -68,6 +68,12 @@ variable "post_clone_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_clone_script" {
|
||||
description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
# Remove query parameters and fragments from the URL
|
||||
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
|
||||
@@ -89,6 +95,32 @@ locals {
|
||||
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
|
||||
# Encode the post_clone_script for passing to the shell script
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
# Encode the pre_clone_script for passing to the shell script
|
||||
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
|
||||
encoded_extra_args = base64encode(join("\n", var.extra_args))
|
||||
|
||||
# Module directory paths (matches coder-utils convention)
|
||||
# Use folder_name so two git-clone instances in the same template get
|
||||
# separate script and log directories.
|
||||
module_dir = "$HOME/.coder-modules/coder/git-clone/${local.folder_name}"
|
||||
scripts_directory = "${local.module_dir}/scripts"
|
||||
log_directory = "${local.module_dir}/logs"
|
||||
clone_script_path = "${local.scripts_directory}/clone.sh"
|
||||
clone_log_path = "${local.log_directory}/clone.log"
|
||||
pre_clone_log_path = "${local.log_directory}/pre_clone.log"
|
||||
post_clone_log_path = "${local.log_directory}/post_clone.log"
|
||||
|
||||
encoded_clone_script = base64encode(templatefile("${path.module}/run.sh", {
|
||||
CLONE_PATH = local.clone_path,
|
||||
REPO_URL = local.clone_url,
|
||||
BRANCH_NAME = local.branch_name,
|
||||
EXTRA_ARGS = local.encoded_extra_args,
|
||||
POST_CLONE_SCRIPT = local.encoded_post_clone_script,
|
||||
PRE_CLONE_SCRIPT = local.encoded_pre_clone_script,
|
||||
SCRIPTS_DIR = local.scripts_directory,
|
||||
PRE_CLONE_LOG_PATH = local.pre_clone_log_path,
|
||||
POST_CLONE_LOG_PATH = local.post_clone_log_path,
|
||||
}))
|
||||
}
|
||||
|
||||
output "repo_dir" {
|
||||
@@ -122,14 +154,21 @@ output "branch_name" {
|
||||
}
|
||||
|
||||
resource "coder_script" "git_clone" {
|
||||
agent_id = var.agent_id
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
CLONE_PATH = local.clone_path,
|
||||
REPO_URL : local.clone_url,
|
||||
BRANCH_NAME : local.branch_name,
|
||||
DEPTH = var.depth,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
|
||||
})
|
||||
agent_id = var.agent_id
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p "${local.module_dir}"
|
||||
mkdir -p "${local.scripts_directory}"
|
||||
mkdir -p "${local.log_directory}"
|
||||
|
||||
echo -n '${local.encoded_clone_script}' | base64 -d > "${local.clone_script_path}"
|
||||
chmod +x "${local.clone_script_path}"
|
||||
|
||||
"${local.clone_script_path}" 2>&1 | tee "${local.clone_log_path}"
|
||||
EOT
|
||||
display_name = "Git Clone"
|
||||
icon = "/icon/git.svg"
|
||||
run_on_start = true
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${REPO_URL}"
|
||||
CLONE_PATH="${CLONE_PATH}"
|
||||
BRANCH_NAME="${BRANCH_NAME}"
|
||||
# Expand home if it's specified!
|
||||
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
|
||||
DEPTH="${DEPTH}"
|
||||
EXTRA_ARGS="${EXTRA_ARGS}"
|
||||
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
|
||||
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
|
||||
SCRIPTS_DIR="${SCRIPTS_DIR}"
|
||||
PRE_CLONE_LOG_PATH="${PRE_CLONE_LOG_PATH}"
|
||||
POST_CLONE_LOG_PATH="${POST_CLONE_LOG_PATH}"
|
||||
|
||||
# Check if the variable is empty...
|
||||
if [ -z "$REPO_URL" ]; then
|
||||
@@ -33,23 +39,32 @@ if [ ! -d "$CLONE_PATH" ]; then
|
||||
mkdir -p "$CLONE_PATH"
|
||||
fi
|
||||
|
||||
# Run pre-clone script if provided
|
||||
if [ -n "$PRE_CLONE_SCRIPT" ]; then
|
||||
echo "Running pre-clone script..."
|
||||
PRE_CLONE_PATH="$SCRIPTS_DIR/pre_clone.sh"
|
||||
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_PATH"
|
||||
chmod +x "$PRE_CLONE_PATH"
|
||||
"$PRE_CLONE_PATH" 2>&1 | tee "$PRE_CLONE_LOG_PATH"
|
||||
fi
|
||||
|
||||
# Build optional git clone flags
|
||||
extra_args=()
|
||||
if [ -n "$EXTRA_ARGS" ]; then
|
||||
while IFS= read -r arg || [ -n "$arg" ]; do
|
||||
[ -n "$arg" ] && extra_args+=("$arg")
|
||||
done < <(echo "$EXTRA_ARGS" | base64 -d)
|
||||
fi
|
||||
|
||||
# Check if the directory is empty
|
||||
# and if it is, clone the repo, otherwise skip cloning
|
||||
if [ -z "$(ls -A "$CLONE_PATH")" ]; then
|
||||
if [ -z "$BRANCH_NAME" ]; then
|
||||
echo "Cloning $REPO_URL to $CLONE_PATH..."
|
||||
if [ "$DEPTH" -gt 0 ]; then
|
||||
git clone --depth "$DEPTH" "$REPO_URL" "$CLONE_PATH"
|
||||
else
|
||||
git clone "$REPO_URL" "$CLONE_PATH"
|
||||
fi
|
||||
git clone $${extra_args[@]+"$${extra_args[@]}"} "$REPO_URL" "$CLONE_PATH"
|
||||
else
|
||||
echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..."
|
||||
if [ "$DEPTH" -gt 0 ]; then
|
||||
git clone --depth "$DEPTH" -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
|
||||
else
|
||||
git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH"
|
||||
fi
|
||||
git clone $${extra_args[@]+"$${extra_args[@]}"} -b "$BRANCH_NAME" "$REPO_URL" "$CLONE_PATH"
|
||||
fi
|
||||
else
|
||||
echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
|
||||
@@ -58,10 +73,9 @@ fi
|
||||
# Run post-clone script if provided
|
||||
if [ -n "$POST_CLONE_SCRIPT" ]; then
|
||||
echo "Running post-clone script..."
|
||||
POST_CLONE_TMP=$(mktemp)
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP"
|
||||
chmod +x "$POST_CLONE_TMP"
|
||||
POST_CLONE_PATH="$SCRIPTS_DIR/post_clone.sh"
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_PATH"
|
||||
chmod +x "$POST_CLONE_PATH"
|
||||
cd "$CLONE_PATH" || exit
|
||||
$POST_CLONE_TMP
|
||||
rm "$POST_CLONE_TMP"
|
||||
"$POST_CLONE_PATH" 2>&1 | tee "$POST_CLONE_LOG_PATH"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
icon: ../../../.icons/coder.svg
|
||||
sources:
|
||||
- repo: coder/skills@main
|
||||
skills:
|
||||
setup:
|
||||
display_name: Coder Setup
|
||||
icon: ../../../.icons/coder.svg
|
||||
tags: [coder, deployment, configuration]
|
||||
modules:
|
||||
display_name: Coder Modules
|
||||
icon: ../../../.icons/coder-modules.svg
|
||||
tags: [coder, terraform, modules]
|
||||
templates:
|
||||
display_name: Coder Templates
|
||||
icon: ../../../.icons/coder-templates.svg
|
||||
tags: [coder, terraform, templates]
|
||||
---
|
||||
|
||||
# Coder Skills
|
||||
|
||||
Agent skills maintained by [Coder](https://coder.com) for installing,
|
||||
configuring, and developing with the Coder platform.
|
||||
|
||||
Skills are sourced from [coder/skills](https://github.com/coder/skills)
|
||||
and served through the registry's API, MCP tools, and
|
||||
[well-known discovery endpoint](https://agentskills.io/specification).
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | Description |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |
|
||||
| [Coder Modules](https://registry.coder.com/skills/coder/modules) | Add or update Coder modules (from registry.coder.com/modules) inside an existing Coder template. Covers IDEs, AI agents, secrets, dev environment tools, and cloud regions. |
|
||||
| [Coder Templates](https://registry.coder.com/skills/coder/templates) | Author, edit, push, or version a Coder template. Covers starter selection, template anatomy, parameters, validation, push, and first-workspace verification. |
|
||||
Reference in New Issue
Block a user