mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76c7371ed9 | |||
| 139fadb975 | |||
| e873e43d6b | |||
| 20051c7089 | |||
| 1601ab3e8b | |||
| f9802456ce | |||
| ee219a8b17 | |||
| 4ca251f448 | |||
| 99510a1f75 | |||
| 297b07190f | |||
| bce0897099 | |||
| 6b8d89daba | |||
| c4661ae365 |
@@ -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 |
@@ -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
|
||||
@@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ locals {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = local.claude_workdir
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -78,7 +78,7 @@ resource "coder_app" "claude" {
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_ai_gateway = true
|
||||
@@ -95,6 +95,33 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
|
||||
> [!CAUTION]
|
||||
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
|
||||
|
||||
### Enterprise policy via managed settings
|
||||
|
||||
The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
|
||||
managed_settings = {
|
||||
permissions = {
|
||||
defaultMode = "acceptEdits"
|
||||
disableBypassPermissionsMode = "disable"
|
||||
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
|
||||
}
|
||||
env = {
|
||||
DISABLE_TELEMETRY = "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
|
||||
@@ -102,7 +129,7 @@ This example shows version pinning, a pre-installed binary path, a custom model,
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -166,7 +193,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -252,7 +279,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -309,7 +336,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
@@ -350,7 +377,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
anthropic_api_key = "xxxx-xxxxx-xxxx"
|
||||
|
||||
@@ -382,10 +382,13 @@ describe("claude-code", async () => {
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.autoUpdaterStatus).toBe("disabled");
|
||||
expect(parsed.hasCompletedOnboarding).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
|
||||
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
|
||||
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
|
||||
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
|
||||
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
|
||||
// not user-writable ~/.claude.json acceptance flags.
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
||||
expect(parsed.autoModeAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("standalone-mode-with-oauth-token", async () => {
|
||||
@@ -413,7 +416,7 @@ describe("claude-code", async () => {
|
||||
);
|
||||
const parsed = JSON.parse(claudeConfig);
|
||||
expect(parsed.hasCompletedOnboarding).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
|
||||
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("standalone-mode-no-auth", async () => {
|
||||
@@ -436,6 +439,49 @@ describe("claude-code", async () => {
|
||||
expect(resp.stdout.trim()).toBe("ABSENT");
|
||||
});
|
||||
|
||||
test("claude-managed-settings-written", async () => {
|
||||
const { id, scripts } = await setup({
|
||||
moduleVariables: {
|
||||
managed_settings: JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "acceptEdits",
|
||||
disableBypassPermissionsMode: "disable",
|
||||
deny: ["Bash(rm -rf*)"],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const policy = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /etc/claude-code/managed-settings.d/10-coder.json",
|
||||
]);
|
||||
expect(policy.exitCode).toBe(0);
|
||||
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
|
||||
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
|
||||
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Wrote Claude Code managed settings");
|
||||
});
|
||||
|
||||
test("claude-managed-settings-not-set", async () => {
|
||||
const { id, scripts } = await setup();
|
||||
await runScripts(id, scripts);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
|
||||
]);
|
||||
expect(resp.stdout.trim()).toBe("ABSENT");
|
||||
});
|
||||
|
||||
test("telemetry-otel", async () => {
|
||||
const { coderEnvVars } = await setup({
|
||||
moduleVariables: {
|
||||
|
||||
@@ -102,6 +102,12 @@ variable "claude_binary_path" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "managed_settings" {
|
||||
type = any
|
||||
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_ai_gateway" {
|
||||
type = bool
|
||||
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
|
||||
@@ -237,6 +243,7 @@ locals {
|
||||
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
|
||||
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
|
||||
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
|
||||
})
|
||||
module_dir_name = ".coder-modules/coder/claude-code"
|
||||
}
|
||||
|
||||
@@ -283,3 +283,47 @@ run "test_workdir_optional" {
|
||||
error_message = "workdir should default to null when omitted"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_managed_settings" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-managed-settings"
|
||||
workdir = "/home/coder/project"
|
||||
managed_settings = {
|
||||
permissions = {
|
||||
defaultMode = "acceptEdits"
|
||||
disableBypassPermissionsMode = "disable"
|
||||
deny = ["Bash(rm -rf*)"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
|
||||
error_message = "managed_settings should accept the permissions object"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
|
||||
error_message = "install script should reference the managed-settings.d drop-in directory"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
|
||||
error_message = "install script should embed the base64-encoded managed_settings JSON"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_managed_settings_default_null" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-managed-settings-default"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.managed_settings == null
|
||||
error_message = "managed_settings should default to null when omitted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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"
|
||||
|
||||
@@ -29,6 +30,7 @@ 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 "--------------------------------"
|
||||
|
||||
@@ -144,6 +146,32 @@ function setup_claude_configurations() {
|
||||
|
||||
}
|
||||
|
||||
function write_managed_settings() {
|
||||
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local dropin_dir="/etc/claude-code/managed-settings.d"
|
||||
local target="$${dropin_dir}/10-coder.json"
|
||||
|
||||
if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
|
||||
echo "Warning: managed_settings is not valid JSON, skipping policy write"
|
||||
return
|
||||
fi
|
||||
|
||||
if command_exists sudo; then
|
||||
sudo mkdir -p "$${dropin_dir}"
|
||||
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
|
||||
sudo chmod 0644 "$${target}"
|
||||
else
|
||||
mkdir -p "$${dropin_dir}"
|
||||
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
|
||||
chmod 0644 "$${target}"
|
||||
fi
|
||||
|
||||
echo "Wrote Claude Code managed settings to $${target}"
|
||||
}
|
||||
|
||||
function configure_standalone_mode() {
|
||||
echo "Configuring Claude Code for standalone mode..."
|
||||
|
||||
@@ -158,8 +186,6 @@ function configure_standalone_mode() {
|
||||
echo "Updating existing Claude configuration at $${claude_config}"
|
||||
|
||||
jq '.autoUpdaterStatus = "disabled" |
|
||||
.autoModeAccepted = true |
|
||||
.bypassPermissionsModeAccepted = true |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
.hasCompletedOnboarding = true' \
|
||||
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
|
||||
@@ -168,8 +194,6 @@ function configure_standalone_mode() {
|
||||
cat > "$${claude_config}" << EOF
|
||||
{
|
||||
"autoUpdaterStatus": "disabled",
|
||||
"autoModeAccepted": true,
|
||||
"bypassPermissionsModeAccepted": true,
|
||||
"hasAcknowledgedCostThreshold": true,
|
||||
"hasCompletedOnboarding": true
|
||||
}
|
||||
@@ -189,4 +213,5 @@ EOF
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
write_managed_settings
|
||||
configure_standalone_mode
|
||||
|
||||
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "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