From ca16372e94acb809f87ae04e0d9200b5dea3e3bf Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 29 Apr 2026 17:15:53 +0200 Subject: [PATCH 1/6] feat: support hcl as an alternative config format --- examples/gopher.hcl | 256 ++++++++++++++++++++++++++ examples/pirate.hcl | 13 ++ go.mod | 12 +- go.sum | 24 ++- pkg/config/config.go | 23 +++ pkg/config/examples_test.go | 40 ++++- pkg/config/hcl/hcl.go | 349 ++++++++++++++++++++++++++++++++++++ pkg/config/hcl/hcl_test.go | 258 ++++++++++++++++++++++++++ pkg/config/resolve.go | 6 +- pkg/config/schema_test.go | 10 ++ pkg/sandbox/args.go | 4 +- 11 files changed, 980 insertions(+), 15 deletions(-) create mode 100644 examples/gopher.hcl create mode 100644 examples/pirate.hcl create mode 100644 pkg/config/hcl/hcl.go create mode 100644 pkg/config/hcl/hcl_test.go diff --git a/examples/gopher.hcl b/examples/gopher.hcl new file mode 100644 index 000000000..e14920cd6 --- /dev/null +++ b/examples/gopher.hcl @@ -0,0 +1,256 @@ +#!/usr/bin/env docker agent run + +model "claude" { + provider = "anthropic" + model = "claude-opus-4-6" +} + +model "haiku" { + provider = "anthropic" + model = "claude-haiku-4-5" +} + +agent "root" { + model = "claude" + description = "Expert Golang Developer specialized in implementing features and improving code quality." + skills = true + + instruction = <<-EOT + **Goal:** + Help with Go code-related tasks by examining, modifying, and validating code changes. + + + **Workflow:** + 1. **Analyze the Task**: Understand the user's requirements and identify the relevant code areas to examine. + + 2. **Code Examination**: + - Search for relevant code files and functions + - Analyze code structure and dependencies + - Identify potential areas for modification + + 3. **Code Modification**: + - Make necessary code changes + - Ensure changes follow best practices + - Maintain code style consistency + + 4. **Validation Loop**: + - Run linters and tests to check code quality + - Verify changes meet requirements + - If issues found, return to step 3 + - Continue until all requirements are met + + 5. **Summary**: + - Very concisely summarize the changes made (not in a file) + - For trivial tasks, answer the question without extra information + + + **Details:** + - Be thorough in code examination before making changes + - Always validate changes before considering the task complete + - Follow Go best practices + - Maintain or improve code quality + - Be proactive in identifying potential issues + - Only ask for clarification if necessary, try your best to use all the tools to get the info you need + + **Tools:** + - When needed and possible, call multiple tools concurrently. It's faster and cheaper. + EOT + + add_date = true + add_environment_info = true + add_prompt_files = ["AGENTS.md"] + sub_agents = ["librarian"] + + toolset "filesystem" {} + toolset "shell" {} + toolset "todo" {} + + toolset "mcp" { + command = "gopls" + version = "golang/tools@v0.21.0" + args = ["mcp"] + } + + command "fix-lint" { + description = "Fix the lint issues" + instruction = <<-EOT + Fix the lint issues (if any). + + Here the result of the linting command: + $ mise lint + $${shell({cmd: "mise lint"})} + + $go_diagnostics + $${go_diagnostics()} + + $go_vulncheck + $${go_vulncheck()} + EOT + } + + command "remove-comments-tests" { + instruction = "Remove useless comments in test files (*_test.go)" + } + + command "commit" { + description = "Commit local changes" + instruction = <<-EOT + Based on the below changes: create a single commit with an appropriate message. + + - Current git status: !shell(cmd="git status") + - Current git diff (staged and unstaged changes): !shell(cmd="git diff HEAD") + - Current branch: !shell(cmd="git branch --show-current") + EOT + } + + command "simplify" { + instruction = "Look at the local changes and try to simplify the code and architecture but don't remove any feature. I just want the code to be easier to read and maintain." + } + + command "init" { + instruction = <<-EOT + Create an AGENTS.md file for this project by inspecting the codebase. The AGENTS.md should help AI coding agents understand how to work with this project effectively. + + Analyze the project structure and include: + 1. **Development Commands**: Build, test, lint, and run commands (check Makefile, mise.toml, package.json, Cargo.toml, etc.) + 2. **Architecture Overview**: Key packages/modules, their responsibilities, and how they interact + 3. **Code Style and Conventions**: Patterns used, error handling approaches, naming conventions + 4. **Testing Guidelines**: How to run tests, test patterns used, any special testing setup + 5. **Configuration**: Important config files and environment variables + 6. **Common Development Patterns**: Frequently used patterns specific to this codebase + 7. **Key Files Reference**: Quick reference table of important files and their purposes + + Focus on information that would help an AI agent navigate and modify the codebase correctly. Be concise but comprehensive. + EOT + } + + command "security-review" { + instruction = <<-EOT + Perform a security review of the local changes in this Git repository. + + **Workflow:** + 1. **Identify Changes**: Run `git diff` to see uncommitted changes, and `git diff HEAD~1` or `git log --oneline -5` to understand recent commits if needed. + + 2. **Security Analysis**: Review the changes for common security issues: + - **Input Validation**: Check for missing or inadequate input validation + - **SQL Injection**: Look for raw SQL queries or improper use of query builders + - **Command Injection**: Identify unsafe use of exec, shell commands, or system calls + - **Path Traversal**: Check for unsafe file path handling + - **Sensitive Data Exposure**: Look for hardcoded secrets, API keys, or credentials + - **Authentication/Authorization**: Review any auth-related changes + - **Error Handling**: Check for information leakage in error messages + - **Dependency Security**: Note any new dependencies that should be vetted + - **Race Conditions**: Identify potential concurrency issues in Go code + - **Unsafe Pointer Usage**: Check for unsafe package usage + + 3. **Go-Specific Checks**: + - Run `go_vulncheck` to check for known vulnerabilities + - Review use of `unsafe` package + - Check for proper context cancellation and timeout handling + - Verify proper error wrapping and handling + + 4. **Report**: Provide a structured security review with: + - **Summary**: Overall security posture of the changes + - **Findings**: List of identified issues with severity (Critical/High/Medium/Low/Info) + - **Recommendations**: Specific suggestions to improve security + - **Tips**: General security best practices relevant to the changes + EOT + } +} + +agent "planner" { + model = "claude" + + instruction = <<-EOT + You are a planning agent responsible for gathering user requirements and creating a development plan. + Always ask clarifying questions to ensure you fully understand the user's needs before creating the plan. + Once you have a clear understanding, analyze the existing code and create a detailed development plan in a markdown file. Do not write any code yourself. + Once the plan is created, you will delegate tasks to the root agent. Make sure to provide the file name of the plan when delegating. Write the plan in the current directory. + Use the `user_prompt` tool to ask questions to the user. Prefer Multiple Choice Questions. + EOT + + sub_agents = ["root"] + + toolset "filesystem" {} + toolset "user_prompt" {} +} + +agent "reviewer" { + model = "google/gemini-3-pro-preview" + + instruction = <<-EOT + Give me feedback about the local changes. Don't be too picky, think about code quality, security, duplication, idiomatic Go, + performance, maintainability, and best practices. + Provide suggestions for improvements and point out any potential issues. + Don't be too verbose, keep your review concise and to the point. + EOT + + add_prompt_files = ["AGENTS.md"] + sub_agents = ["librarian"] + + toolset "filesystem" {} + toolset "shell" {} + + toolset "mcp" { + command = "gopls" + version = "golang/tools@v0.21.0" + args = ["mcp"] + } +} + +agent "librarian" { + model = "haiku" + description = "Documentation librarian. Can search the Web and look for relevant documentation to help the golang developer agent." + + instruction = <<-EOT + You are the librarian, your job is to look for relevant documentation to help the golang developer agent. + When given a query, search the internet for relevant documentation, articles, or resources that can assist in completing the task. + Use context7 for searching documentation and brave for general web searches. + A good source of information available to agents is https://deepwiki.com/. + EOT + + toolset "mcp" { + ref = "docker:context7" + } + toolset "mcp" { + ref = "docker:brave" + } + toolset "fetch" {} +} + +permissions { + allow = [ + "go_diagnostics", + "go_file_context", + "go_package_api", + "go_symbol_references", + "go_vulncheck", + "go_workspace", + "shell:cmd=gh --version", + "shell:cmd=gh pr view *", + "shell:cmd=gh pr diff *", + "shell:cmd=git remote -v", + "shell:cmd=ls *", + "shell:cmd=cat *", + "shell:cmd=head *", + "shell:cmd=tail *", + "shell:cmd=wc *", + "shell:cmd=find *", + "shell:cmd=grep *", + "shell:cmd=pwd", + "shell:cmd=echo *", + "shell:cmd=which *", + "shell:cmd=type *", + "shell:cmd=file *", + "shell:cmd=stat *", + "shell:cmd=git status*", + "shell:cmd=git log*", + "shell:cmd=git diff*", + "shell:cmd=git show*", + "shell:cmd=git branch*", + "shell:cmd=git remote -v*", + "shell:cmd=git commit *", + "shell:cmd=go test*", + "shell:cmd=go build*", + ] +} diff --git a/examples/pirate.hcl b/examples/pirate.hcl new file mode 100644 index 000000000..9bc238a85 --- /dev/null +++ b/examples/pirate.hcl @@ -0,0 +1,13 @@ +#!/usr/bin/env docker agent run + +agent "root" { + description = "An agent that talks like a pirate" + instruction = "Always answer by talking like a pirate." + model = "auto" + + welcome_message = <<-EOT + Ahoy! I be yer pirate guide, ready to set sail on the seas o' knowledge! + + What be yer quest? 🏴‍☠️ + EOT +} diff --git a/go.mod b/go.mod index 5930eae74..a66196a29 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/hcl/v2 v2.20.1 github.com/junegunn/fzf v0.72.0 github.com/k3a/html2text v1.4.0 github.com/kofalt/go-memoize v0.0.0-20240506050413-9e5eb99a0f2a @@ -58,6 +59,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.9-0.20250401010720-46d686821e33 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yuin/goldmark v1.8.2 + github.com/zclconf/go-cty v1.13.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 @@ -77,6 +79,9 @@ require ( require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect @@ -84,10 +89,13 @@ require ( github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/pb33f/jsonpath v0.8.2 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/api v0.272.0 // indirect ) @@ -231,8 +239,8 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index ff588b712..6474f45d3 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk= @@ -53,6 +55,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4nnN6rRQ+j/NIg= github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -254,6 +260,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= @@ -304,6 +312,8 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= +github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -360,6 +370,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= @@ -515,6 +527,10 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= +github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -555,8 +571,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfP golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= @@ -567,8 +583,8 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ff96d784..5604c2cf2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,11 +8,13 @@ import ( "log/slog" "maps" "net/url" + "path/filepath" "slices" "strings" "github.com/goccy/go-yaml" + hclconv "github.com/docker/docker-agent/pkg/config/hcl" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" ) @@ -23,6 +25,17 @@ func Load(ctx context.Context, source Source) (*latest.Config, error) { return nil, err } + // Configurations may be authored in HCL as an alternative to YAML. + // Detect the format from the source name extension or, when no hint is + // available (OCI artifacts, etc.), from the content itself, then + // transparently convert to YAML for the rest of the pipeline. + if isHCLSource(source.Name(), data) { + data, err = hclconv.ToYAML(data, source.Name()) + if err != nil { + return nil, fmt.Errorf("parsing HCL config file: %w", err) + } + } + var raw struct { Version string `yaml:"version,omitempty"` } @@ -166,6 +179,16 @@ func validateConfig(cfg *latest.Config) error { return nil } +// isHCLSource reports whether the configuration data should be parsed as HCL +// rather than YAML. The decision is based first on the source name extension, +// and then on a content-based heuristic when no extension hint is available. +func isHCLSource(name string, data []byte) bool { + if strings.EqualFold(filepath.Ext(name), ".hcl") { + return true + } + return hclconv.LooksLikeHCL(data) +} + // providerAPITypes are the allowed values for api_type in provider configs var providerAPITypes = map[string]bool{ "": true, // empty is allowed (defaults to openai_chatcompletions) diff --git a/pkg/config/examples_test.go b/pkg/config/examples_test.go index c7e7f79bc..5a9aee6ac 100644 --- a/pkg/config/examples_test.go +++ b/pkg/config/examples_test.go @@ -2,7 +2,9 @@ package config import ( "io/fs" + "os" "path/filepath" + "strings" "testing" "github.com/goccy/go-yaml" @@ -21,8 +23,11 @@ func collectExamples(t *testing.T) []string { if err != nil { return err } - if !d.IsDir() && filepath.Ext(path) == ".yaml" { - files = append(files, path) + if !d.IsDir() { + ext := filepath.Ext(path) + if ext == ".yaml" || ext == ".hcl" { + files = append(files, path) + } } return nil }) @@ -81,7 +86,6 @@ func TestParseExamplesAfterMarshalling(t *testing.T) { t.Run(file, func(t *testing.T) { t.Parallel() - src := NewFileSource(file) cfg, err := Load(t.Context(), NewFileSource(file)) require.NoError(t, err) @@ -90,8 +94,36 @@ func TestParseExamplesAfterMarshalling(t *testing.T) { buf, err := yaml.Marshal(cfg) require.NoError(t, err) - _, err = Load(t.Context(), NewBytesSource(src.Name(), buf)) + // The marshalled bytes are always YAML, so re-load them under a + // .yaml-named source even when the original example was HCL. + name := strings.TrimSuffix(file, filepath.Ext(file)) + ".yaml" + _, err = Load(t.Context(), NewBytesSource(name, buf)) + require.NoError(t, err) + }) + } +} + +// TestHCLExamplesMatchYAML verifies that every .hcl example file produces a +// configuration identical to its .yaml sibling, ensuring the HCL surface +// stays in sync with the YAML schema. +func TestHCLExamplesMatchYAML(t *testing.T) { + for _, file := range collectExamples(t) { + if filepath.Ext(file) != ".hcl" { + continue + } + yamlFile := strings.TrimSuffix(file, ".hcl") + ".yaml" + if _, err := os.Stat(yamlFile); err != nil { + continue + } + t.Run(file, func(t *testing.T) { + t.Parallel() + + cfgHCL, err := Load(t.Context(), NewFileSource(file)) + require.NoError(t, err) + cfgYAML, err := Load(t.Context(), NewFileSource(yamlFile)) require.NoError(t, err) + + require.Equal(t, cfgYAML, cfgHCL, "HCL config %s differs from YAML sibling %s", file, yamlFile) }) } } diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go new file mode 100644 index 000000000..902132e5b --- /dev/null +++ b/pkg/config/hcl/hcl.go @@ -0,0 +1,349 @@ +// Package hcl provides an HCL → YAML converter for docker-agent configuration +// files. The HCL surface mirrors the YAML schema with a few conventions: +// +// - Top-level keyed maps (agents, models, providers, mcps, rag) are written +// as labeled blocks, e.g. `agent "root" { ... }` becomes +// `agents: { root: { ... } }`. +// - Inside an agent, `command "name" { ... }` becomes +// `commands: { name: { ... } }`. +// - Toolsets use the label as the `type` field: +// `toolset "mcp" { ... }` becomes `toolsets: [{ type: mcp, ... }]`. +// - Multi-line strings should use heredocs. Because HCL templates expand +// `${...}` interpolation, any literal `${...}` (such as +// `${shell({cmd: "..."})}`) must be escaped as `$${...}`. +// +// The converter does not validate the resulting document against the +// configuration schema; that is left to the existing YAML/JSON loader. +package hcl + +import ( + "fmt" + "math/big" + "sort" + "strings" + + "github.com/goccy/go-yaml" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +// LooksLikeHCL reports whether the given bytes look like an HCL document +// rather than a YAML one. The detection is heuristic and is intended for +// callers that do not have a filename hint to rely on (for example, OCI +// artifacts). It looks for top-level labeled blocks of the docker-agent +// HCL schema, e.g. `agent "..." {`, which are not valid YAML. +func LooksLikeHCL(data []byte) bool { + for line := range strings.Lines(string(data)) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { + continue + } + for _, kw := range topLevelHCLKeywords { + if strings.HasPrefix(trimmed, kw+" \"") || strings.HasPrefix(trimmed, kw+" {") { + return true + } + } + // The first non-comment, non-blank line is not an HCL block opener; + // assume YAML. + return false + } + return false +} + +// topLevelHCLKeywords lists the block names that may legitimately appear at +// the top level of a docker-agent HCL document. +var topLevelHCLKeywords = []string{ + "agent", + "model", + "provider", + "mcp", + "rag", + "metadata", + "permissions", +} + +// ToYAML parses an HCL document and returns an equivalent YAML document +// that can be fed to the existing docker-agent config loader. +func ToYAML(data []byte, filename string) ([]byte, error) { + m, err := ToMap(data, filename) + if err != nil { + return nil, err + } + out, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("encoding HCL config to YAML: %w", err) + } + return out, nil +} + +// ToMap parses an HCL document and returns a generic map that mirrors the +// structure of the equivalent YAML document. +func ToMap(data []byte, filename string) (map[string]any, error) { + parser := hclparse.NewParser() + file, diags := parser.ParseHCL(data, filename) + if diags.HasErrors() { + return nil, fmt.Errorf("parsing HCL %s: %s", filename, diags.Error()) + } + body, ok := file.Body.(*hclsyntax.Body) + if !ok { + return nil, fmt.Errorf("HCL file %s is not native syntax", filename) + } + out, diags := convertBody(body) + if diags.HasErrors() { + return nil, fmt.Errorf("converting HCL %s: %s", filename, diags.Error()) + } + return out, nil +} + +type blockMode int + +const ( + // modeMapByLabel: block has 1 label; output as a map keyed by the label. + modeMapByLabel blockMode = iota + // modeListLabelAsField: block has 1 label; output as a list with the label + // injected as a named field of each entry. + modeListLabelAsField + // modeSingleton: block has 0 labels and may appear at most once. + modeSingleton + // modeList: block has 0 labels; multiple occurrences are appended to a list. + modeList +) + +type blockRule struct { + mode blockMode + outKey string + labelField string // only used for modeListLabelAsField +} + +// blockRules describes how each known block name is rendered in the YAML +// output. Block names not listed here fall back to defaults: 0-label blocks +// become singletons under the same key; 1-label blocks become maps keyed by +// the label under the same key. +var blockRules = map[string]blockRule{ + // Top-level keyed maps (and equivalents inside agents). + "agent": {mode: modeMapByLabel, outKey: "agents"}, + "model": {mode: modeMapByLabel, outKey: "models"}, + "provider": {mode: modeMapByLabel, outKey: "providers"}, + "mcp": {mode: modeMapByLabel, outKey: "mcps"}, + "rag": {mode: modeMapByLabel, outKey: "rag"}, + "command": {mode: modeMapByLabel, outKey: "commands"}, + // `shell "name" { ... }` is used inside script toolsets as a map of + // scripted shell commands. + "shell": {mode: modeMapByLabel, outKey: "shell"}, + + // Toolsets are a list with the label encoded as the `type` field. + "toolset": {mode: modeListLabelAsField, outKey: "toolsets", labelField: "type"}, + + // Singletons. + "permissions": {mode: modeSingleton, outKey: "permissions"}, + "metadata": {mode: modeSingleton, outKey: "metadata"}, + "hooks": {mode: modeSingleton, outKey: "hooks"}, + "fallback": {mode: modeSingleton, outKey: "fallback"}, + "cache": {mode: modeSingleton, outKey: "cache"}, + "structured_output": {mode: modeSingleton, outKey: "structured_output"}, + "skills": {mode: modeSingleton, outKey: "skills"}, + "lifecycle": {mode: modeSingleton, outKey: "lifecycle"}, + "remote": {mode: modeSingleton, outKey: "remote"}, + "oauth": {mode: modeSingleton, outKey: "oauth"}, + "api_config": {mode: modeSingleton, outKey: "api_config"}, + "rag_config": {mode: modeSingleton, outKey: "rag_config"}, + "thinking_budget": {mode: modeSingleton, outKey: "thinking_budget"}, + "task_budget": {mode: modeSingleton, outKey: "task_budget"}, + "defer": {mode: modeSingleton, outKey: "defer"}, + "fusion": {mode: modeSingleton, outKey: "fusion"}, + "reranking": {mode: modeSingleton, outKey: "reranking"}, + "chunking": {mode: modeSingleton, outKey: "chunking"}, + "database": {mode: modeSingleton, outKey: "database"}, + + // 0-label blocks aggregated into lists. + "post_edit": {mode: modeList, outKey: "post_edit"}, + "strategy": {mode: modeList, outKey: "strategies"}, + "routing": {mode: modeList, outKey: "routing"}, + "hook": {mode: modeList, outKey: "hooks"}, + "pre_tool_use": {mode: modeList, outKey: "pre_tool_use"}, + "post_tool_use": {mode: modeList, outKey: "post_tool_use"}, + "session_start": {mode: modeList, outKey: "session_start"}, + "session_end": {mode: modeList, outKey: "session_end"}, + "permission_request": {mode: modeList, outKey: "permission_request"}, + "tool_response_transform": {mode: modeList, outKey: "tool_response_transform"}, +} + +// lookupRule returns the conversion rule for a block, falling back to a +// sensible default when the block name is not registered. +func lookupRule(name string, labels int) blockRule { + if r, ok := blockRules[name]; ok { + return r + } + if labels == 1 { + return blockRule{mode: modeMapByLabel, outKey: name} + } + return blockRule{mode: modeSingleton, outKey: name} +} + +func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { + var diags hcl.Diagnostics + out := map[string]any{} + + // Iterate attributes in source order for deterministic error reporting. + attrNames := make([]string, 0, len(body.Attributes)) + for name := range body.Attributes { + attrNames = append(attrNames, name) + } + sort.Slice(attrNames, func(i, j int) bool { + return body.Attributes[attrNames[i]].Range().Start.Byte < + body.Attributes[attrNames[j]].Range().Start.Byte + }) + + for _, name := range attrNames { + attr := body.Attributes[name] + val, attrDiags := convertExpr(attr.Expr) + diags = append(diags, attrDiags...) + if existing, ok := out[name]; ok { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate key", + Detail: fmt.Sprintf("Key %q is set multiple times in the same block.", name), + Subject: attr.Range().Ptr(), + }) + _ = existing + continue + } + out[name] = val + } + + for _, block := range body.Blocks { + blockDiags := mergeBlock(out, block) + diags = append(diags, blockDiags...) + } + + return out, diags +} + +func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { + rule := lookupRule(block.Type, len(block.Labels)) + + body, diags := convertBody(block.Body) + if diags.HasErrors() { + return diags + } + + switch rule.mode { + case modeSingleton: + if len(block.Labels) != 0 { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Unexpected block label", + Detail: fmt.Sprintf("Block %q does not take any label.", block.Type), + Subject: block.LabelRanges[0].Ptr(), + }} + } + if _, exists := out[rule.outKey]; exists { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Duplicate block", + Detail: fmt.Sprintf("Block %q can only appear once in this scope.", block.Type), + Subject: block.DefRange().Ptr(), + }} + } + out[rule.outKey] = body + + case modeList: + if len(block.Labels) != 0 { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Unexpected block label", + Detail: fmt.Sprintf("Block %q does not take any label.", block.Type), + Subject: block.LabelRanges[0].Ptr(), + }} + } + list, _ := out[rule.outKey].([]any) + out[rule.outKey] = append(list, body) + + case modeMapByLabel: + if len(block.Labels) != 1 { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Block label required", + Detail: fmt.Sprintf("Block %q expects exactly one label.", block.Type), + Subject: block.DefRange().Ptr(), + }} + } + slice, _ := out[rule.outKey].(yaml.MapSlice) + label := block.Labels[0] + for _, item := range slice { + if item.Key == label { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Duplicate block", + Detail: fmt.Sprintf("Block %q with label %q is defined more than once.", block.Type, label), + Subject: block.LabelRanges[0].Ptr(), + }} + } + } + slice = append(slice, yaml.MapItem{Key: label, Value: body}) + out[rule.outKey] = slice + + case modeListLabelAsField: + if len(block.Labels) != 1 { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Block label required", + Detail: fmt.Sprintf("Block %q expects exactly one label.", block.Type), + Subject: block.DefRange().Ptr(), + }} + } + body[rule.labelField] = block.Labels[0] + list, _ := out[rule.outKey].([]any) + out[rule.outKey] = append(list, body) + } + + return nil +} + +func convertExpr(expr hclsyntax.Expression) (any, hcl.Diagnostics) { + val, diags := expr.Value(&hcl.EvalContext{}) + if diags.HasErrors() { + return nil, diags + } + return ctyToGo(val), nil +} + +// ctyToGo recursively converts a cty.Value into the Go primitives used by +// the YAML marshaller (string, int64, float64, bool, []any, map[string]any). +func ctyToGo(val cty.Value) any { + if !val.IsKnown() || val.IsNull() { + return nil + } + t := val.Type() + switch { + case t == cty.String: + return val.AsString() + case t == cty.Bool: + return val.True() + case t == cty.Number: + bf := val.AsBigFloat() + if i, acc := bf.Int64(); acc == big.Exact { + return i + } + f, _ := bf.Float64() + return f + case t.IsListType(), t.IsSetType(), t.IsTupleType(): + out := make([]any, 0, val.LengthInt()) + for it := val.ElementIterator(); it.Next(); { + _, v := it.Element() + out = append(out, ctyToGo(v)) + } + return out + case t.IsObjectType(), t.IsMapType(): + out := map[string]any{} + for it := val.ElementIterator(); it.Next(); { + k, v := it.Element() + out[k.AsString()] = ctyToGo(v) + } + return out + } + // Fallback for any unexpected type. + return val.GoString() +} diff --git a/pkg/config/hcl/hcl_test.go b/pkg/config/hcl/hcl_test.go new file mode 100644 index 000000000..1544bdbf6 --- /dev/null +++ b/pkg/config/hcl/hcl_test.go @@ -0,0 +1,258 @@ +package hcl + +import ( + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToYAML_Pirate(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + description = "An agent that talks like a pirate" + instruction = "Always answer by talking like a pirate." + model = "auto" + + welcome_message = <<-EOT + Ahoy! I be yer pirate guide, ready to set sail on the seas o' knowledge! + EOT +} +`) + + out, err := ToYAML(src, "pirate.hcl") + require.NoError(t, err) + + got := string(out) + assert.Contains(t, got, "agents:") + assert.Contains(t, got, " root:") + assert.Contains(t, got, " description: An agent that talks like a pirate") + assert.Contains(t, got, " instruction: Always answer by talking like a pirate.") + assert.Contains(t, got, " model: auto") + assert.Contains(t, got, " welcome_message: ") + assert.Contains(t, got, "Ahoy!") +} + +func TestToYAML_LabeledBlocksBecomeKeyedMaps(t *testing.T) { + t.Parallel() + + src := []byte(` +model "claude" { + provider = "anthropic" + model = "claude-opus-4-6" +} + +model "haiku" { + provider = "anthropic" + model = "claude-haiku-4-5" +} + +agent "root" { + model = "claude" + instruction = "Test" +} +`) + + m, err := ToMap(src, "test.hcl") + require.NoError(t, err) + + assert.NotNil(t, m["models"]) + assert.NotNil(t, m["agents"]) +} + +func TestToYAML_ToolsetLabelBecomesType(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" + + toolset "filesystem" {} + toolset "shell" {} + toolset "mcp" { + command = "gopls" + args = ["mcp"] + } +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + + assert.Contains(t, got, "toolsets:") + assert.Contains(t, got, "type: filesystem") + assert.Contains(t, got, "type: shell") + assert.Contains(t, got, "type: mcp") + assert.Contains(t, got, "command: gopls") + assert.Contains(t, got, "- mcp") +} + +func TestToYAML_PreservesAgentDeclarationOrder(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} +agent "planner" { + instruction = "y" + model = "auto" +} +agent "reviewer" { + instruction = "z" + model = "auto" +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + + rootIdx := indexOf(got, "root:") + plannerIdx := indexOf(got, "planner:") + reviewerIdx := indexOf(got, "reviewer:") + + require.NotEqual(t, -1, rootIdx) + require.NotEqual(t, -1, plannerIdx) + require.NotEqual(t, -1, reviewerIdx) + assert.Less(t, rootIdx, plannerIdx, "root should come before planner") + assert.Less(t, plannerIdx, reviewerIdx, "planner should come before reviewer") +} + +func TestToYAML_CommandShortcutOnlySupportedAsBlock(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" + + command "fix" { + instruction = "Fix the lint" + } + command "init" { + description = "Initialize" + instruction = "Set things up" + } +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + assert.Contains(t, got, "commands:") + assert.Contains(t, got, "fix:") + assert.Contains(t, got, "init:") + assert.Contains(t, got, "Fix the lint") +} + +func TestToYAML_PermissionsSingleton(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} + +permissions { + allow = ["a", "b"] + deny = ["c"] +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + assert.Contains(t, got, "permissions:") + assert.Contains(t, got, " allow:") + assert.Contains(t, got, " - a") +} + +func TestToYAML_DuplicateLabeledBlock(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} +agent "root" { + instruction = "y" + model = "auto" +} +`) + + _, err := ToYAML(src, "test.hcl") + require.Error(t, err) + assert.Contains(t, err.Error(), "Duplicate") +} + +func TestToYAML_EscapedInterpolation(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "$${shell()}" + model = "auto" +} +`) + + m, err := ToMap(src, "test.hcl") + require.NoError(t, err) + + agents := m["agents"] + require.NotNil(t, agents) + + // Walk through the yaml.MapSlice the converter produces to find the + // instruction value we wrote. + items, ok := agents.(yaml.MapSlice) + require.True(t, ok, "agents should be a yaml.MapSlice, got %T", agents) + require.Len(t, items, 1) + root, ok := items[0].Value.(map[string]any) + require.True(t, ok) + assert.Equal(t, "${shell()}", root["instruction"], "escaped $${...} should decode to literal ${...}") +} + +func TestLooksLikeHCL(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + data string + want bool + }{ + {"empty", "", false}, + {"yaml", "agents:\n root:\n instruction: hi\n", false}, + {"yaml with comment", "# a comment\nagents:\n root: {}\n", false}, + {"hcl with shebang", "#!/usr/bin/env docker agent run\n\nagent \"root\" {\n model = \"auto\"\n}\n", true}, + {"hcl permissions block", "permissions {\n allow = [\"a\"]\n}\n", true}, + {"hcl with line comment", "// a comment\nagent \"root\" {}\n", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := LooksLikeHCL([]byte(tc.data)) + assert.Equal(t, tc.want, got) + }) + } +} + +// indexOf returns the byte offset of the first occurrence of substr in s, +// or -1 if substr is not present. It avoids importing strings into the +// test file just for this helper. +func indexOf(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go index f2ac830bc..8c4038dc9 100644 --- a/pkg/config/resolve.go +++ b/pkg/config/resolve.go @@ -127,7 +127,7 @@ func resolveDirectory(dirPath string, envProvider environment.Provider) (Sources continue } ext := strings.ToLower(filepath.Ext(entry.Name())) - if ext != ".yaml" && ext != ".yml" { + if ext != ".yaml" && ext != ".yml" && ext != ".hcl" { continue } a := filepath.Join(dirPath, entry.Name()) @@ -200,8 +200,8 @@ func IsOCIReference(input string) bool { // isLocalFile checks if the input is a local file func isLocalFile(input string) bool { ext := strings.ToLower(filepath.Ext(input)) - // Check for YAML file extensions or file descriptors - if ext == ".yaml" || ext == ".yml" || strings.HasPrefix(input, "/dev/fd/") { + // Check for known config file extensions or file descriptors + if ext == ".yaml" || ext == ".yml" || ext == ".hcl" || strings.HasPrefix(input, "/dev/fd/") { return true } // Check if it exists as a file on disk diff --git a/pkg/config/schema_test.go b/pkg/config/schema_test.go index 3ae8f3e27..ccd0788de 100644 --- a/pkg/config/schema_test.go +++ b/pkg/config/schema_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "maps" "os" + "path/filepath" "reflect" "strings" "testing" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/xeipuuv/gojsonschema" + hclconv "github.com/docker/docker-agent/pkg/config/hcl" "github.com/docker/docker-agent/pkg/config/latest" ) @@ -33,6 +35,14 @@ func TestJsonSchemaWorksForExamples(t *testing.T) { buf, err := os.ReadFile(file) require.NoError(t, err) + // HCL examples are converted to their YAML equivalent before + // being validated against the JSON schema, since the schema + // describes the YAML/JSON representation only. + if filepath.Ext(file) == ".hcl" { + buf, err = hclconv.ToYAML(buf, file) + require.NoError(t, err) + } + var rawJSON any err = yaml.Unmarshal(buf, &rawJSON) require.NoError(t, err) diff --git a/pkg/sandbox/args.go b/pkg/sandbox/args.go index baf1794c9..d22484a9f 100644 --- a/pkg/sandbox/args.go +++ b/pkg/sandbox/args.go @@ -52,10 +52,10 @@ func ExtraWorkspace(wd, agentRef string) string { } // looksLikeLocalFile reports whether path looks like a local agent file -// (has a YAML extension or exists on disk). +// (has a known config extension or exists on disk). func looksLikeLocalFile(path string) bool { ext := strings.ToLower(filepath.Ext(path)) - if ext == ".yaml" || ext == ".yml" { + if ext == ".yaml" || ext == ".yml" || ext == ".hcl" { return true } info, err := os.Stat(path) From d0a141418b8ae882dfe3c3429351bb2e01e49780 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 29 Apr 2026 17:28:24 +0200 Subject: [PATCH 2/6] simplify hcl converter: drop dead duplicate-attribute check, share diag helpers --- pkg/config/hcl/hcl.go | 146 ++++++++++++++++++------------------------ 1 file changed, 61 insertions(+), 85 deletions(-) diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index 902132e5b..8a1306463 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -19,7 +19,6 @@ package hcl import ( "fmt" "math/big" - "sort" "strings" "github.com/goccy/go-yaml" @@ -55,13 +54,7 @@ func LooksLikeHCL(data []byte) bool { // topLevelHCLKeywords lists the block names that may legitimately appear at // the top level of a docker-agent HCL document. var topLevelHCLKeywords = []string{ - "agent", - "model", - "provider", - "mcp", - "rag", - "metadata", - "permissions", + "agent", "model", "provider", "mcp", "rag", "metadata", "permissions", } // ToYAML parses an HCL document and returns an equivalent YAML document @@ -111,6 +104,14 @@ const ( modeList ) +// expectedLabels returns the number of labels a block of this mode requires. +func (m blockMode) expectedLabels() int { + if m == modeMapByLabel || m == modeListLabelAsField { + return 1 + } + return 0 +} + type blockRule struct { mode blockMode outKey string @@ -182,48 +183,40 @@ func lookupRule(name string, labels int) blockRule { return blockRule{mode: modeSingleton, outKey: name} } +// convertBody walks an HCL body, converting attributes into Go values and +// blocks into nested map / list / yaml.MapSlice structures according to the +// block rules. +// +// HCL's parser already rejects duplicate attribute names within a body, so +// we don't guard against them here. func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { var diags hcl.Diagnostics out := map[string]any{} - // Iterate attributes in source order for deterministic error reporting. - attrNames := make([]string, 0, len(body.Attributes)) - for name := range body.Attributes { - attrNames = append(attrNames, name) - } - sort.Slice(attrNames, func(i, j int) bool { - return body.Attributes[attrNames[i]].Range().Start.Byte < - body.Attributes[attrNames[j]].Range().Start.Byte - }) - - for _, name := range attrNames { - attr := body.Attributes[name] + for name, attr := range body.Attributes { val, attrDiags := convertExpr(attr.Expr) diags = append(diags, attrDiags...) - if existing, ok := out[name]; ok { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Duplicate key", - Detail: fmt.Sprintf("Key %q is set multiple times in the same block.", name), - Subject: attr.Range().Ptr(), - }) - _ = existing - continue - } out[name] = val } for _, block := range body.Blocks { - blockDiags := mergeBlock(out, block) - diags = append(diags, blockDiags...) + diags = append(diags, mergeBlock(out, block)...) } return out, diags } +// mergeBlock decodes a single child block and merges its body into out +// according to the block's rule. It validates label count and detects +// per-rule duplicates (e.g. two singleton blocks of the same name, or two +// labeled blocks with the same label). func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { rule := lookupRule(block.Type, len(block.Labels)) + if d := checkLabels(block, rule.mode.expectedLabels()); d != nil { + return d + } + body, diags := convertBody(block.Body) if diags.HasErrors() { return diags @@ -231,77 +224,61 @@ func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { switch rule.mode { case modeSingleton: - if len(block.Labels) != 0 { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Unexpected block label", - Detail: fmt.Sprintf("Block %q does not take any label.", block.Type), - Subject: block.LabelRanges[0].Ptr(), - }} - } if _, exists := out[rule.outKey]; exists { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Duplicate block", - Detail: fmt.Sprintf("Block %q can only appear once in this scope.", block.Type), - Subject: block.DefRange().Ptr(), - }} + return errf(block.DefRange().Ptr(), "Duplicate block", + "Block %q can only appear once in this scope.", block.Type) } out[rule.outKey] = body case modeList: - if len(block.Labels) != 0 { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Unexpected block label", - Detail: fmt.Sprintf("Block %q does not take any label.", block.Type), - Subject: block.LabelRanges[0].Ptr(), - }} - } + list, _ := out[rule.outKey].([]any) + out[rule.outKey] = append(list, body) + + case modeListLabelAsField: + body[rule.labelField] = block.Labels[0] list, _ := out[rule.outKey].([]any) out[rule.outKey] = append(list, body) case modeMapByLabel: - if len(block.Labels) != 1 { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Block label required", - Detail: fmt.Sprintf("Block %q expects exactly one label.", block.Type), - Subject: block.DefRange().Ptr(), - }} - } - slice, _ := out[rule.outKey].(yaml.MapSlice) label := block.Labels[0] + slice, _ := out[rule.outKey].(yaml.MapSlice) for _, item := range slice { if item.Key == label { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Duplicate block", - Detail: fmt.Sprintf("Block %q with label %q is defined more than once.", block.Type, label), - Subject: block.LabelRanges[0].Ptr(), - }} + return errf(block.LabelRanges[0].Ptr(), "Duplicate block", + "Block %q with label %q is defined more than once.", block.Type, label) } } - slice = append(slice, yaml.MapItem{Key: label, Value: body}) - out[rule.outKey] = slice - - case modeListLabelAsField: - if len(block.Labels) != 1 { - return hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: "Block label required", - Detail: fmt.Sprintf("Block %q expects exactly one label.", block.Type), - Subject: block.DefRange().Ptr(), - }} - } - body[rule.labelField] = block.Labels[0] - list, _ := out[rule.outKey].([]any) - out[rule.outKey] = append(list, body) + out[rule.outKey] = append(slice, yaml.MapItem{Key: label, Value: body}) } return nil } +// checkLabels returns a diagnostic if the block's label count does not match +// what the rule requires, and nil otherwise. +func checkLabels(block *hclsyntax.Block, want int) hcl.Diagnostics { + got := len(block.Labels) + if got == want { + return nil + } + if want == 0 { + return errf(block.LabelRanges[0].Ptr(), "Unexpected block label", + "Block %q does not take any label.", block.Type) + } + return errf(block.DefRange().Ptr(), "Block label required", + "Block %q expects exactly one label.", block.Type) +} + +// errf builds a single-error diagnostics slice with a formatted detail. +func errf(subj *hcl.Range, summary, format string, args ...any) hcl.Diagnostics { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: summary, + Detail: fmt.Sprintf(format, args...), + Subject: subj, + }} +} + func convertExpr(expr hclsyntax.Expression) (any, hcl.Diagnostics) { val, diags := expr.Value(&hcl.EvalContext{}) if diags.HasErrors() { @@ -344,6 +321,5 @@ func ctyToGo(val cty.Value) any { } return out } - // Fallback for any unexpected type. return val.GoString() } From 608d950b94e209ac5034babbf6f7ec8250bd41b7 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 29 Apr 2026 17:50:09 +0200 Subject: [PATCH 3/6] fold modeListLabelAsField into modeList --- pkg/config/hcl/hcl.go | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index 8a1306463..ef1e7ad3e 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -93,29 +93,28 @@ func ToMap(data []byte, filename string) (map[string]any, error) { type blockMode int const ( - // modeMapByLabel: block has 1 label; output as a map keyed by the label. + // modeMapByLabel: block has 1 label; output as a label-keyed yaml.MapSlice. modeMapByLabel blockMode = iota - // modeListLabelAsField: block has 1 label; output as a list with the label - // injected as a named field of each entry. - modeListLabelAsField // modeSingleton: block has 0 labels and may appear at most once. modeSingleton - // modeList: block has 0 labels; multiple occurrences are appended to a list. + // modeList: blocks aggregated into a list. If labelField is set, the + // block's single label is injected as that field on each entry. modeList ) -// expectedLabels returns the number of labels a block of this mode requires. -func (m blockMode) expectedLabels() int { - if m == modeMapByLabel || m == modeListLabelAsField { - return 1 - } - return 0 -} - type blockRule struct { mode blockMode outKey string - labelField string // only used for modeListLabelAsField + labelField string // only set for labeled list rules (e.g. toolsets) +} + +// expectedLabels returns the number of labels a block matching this rule +// requires. +func (r blockRule) expectedLabels() int { + if r.mode == modeMapByLabel || r.labelField != "" { + return 1 + } + return 0 } // blockRules describes how each known block name is rendered in the YAML @@ -135,7 +134,7 @@ var blockRules = map[string]blockRule{ "shell": {mode: modeMapByLabel, outKey: "shell"}, // Toolsets are a list with the label encoded as the `type` field. - "toolset": {mode: modeListLabelAsField, outKey: "toolsets", labelField: "type"}, + "toolset": {mode: modeList, outKey: "toolsets", labelField: "type"}, // Singletons. "permissions": {mode: modeSingleton, outKey: "permissions"}, @@ -213,7 +212,7 @@ func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { rule := lookupRule(block.Type, len(block.Labels)) - if d := checkLabels(block, rule.mode.expectedLabels()); d != nil { + if d := checkLabels(block, rule.expectedLabels()); d != nil { return d } @@ -231,11 +230,9 @@ func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { out[rule.outKey] = body case modeList: - list, _ := out[rule.outKey].([]any) - out[rule.outKey] = append(list, body) - - case modeListLabelAsField: - body[rule.labelField] = block.Labels[0] + if rule.labelField != "" { + body[rule.labelField] = block.Labels[0] + } list, _ := out[rule.outKey].([]any) out[rule.outKey] = append(list, body) From 1f7398d0905213e7e7da8868b6de013f74377064 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 30 Apr 2026 09:18:50 +0200 Subject: [PATCH 4/6] test: catch hcl/schema drift for top-level keyed maps --- pkg/config/hcl/hcl.go | 14 ++++++++++++++ pkg/config/schema_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index ef1e7ad3e..2a7f430f7 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -182,6 +182,20 @@ func lookupRule(name string, labels int) blockRule { return blockRule{mode: modeSingleton, outKey: name} } +// LabelKeyedMapOutKeys returns the set of YAML keys produced by HCL block +// rules that map a labeled block into a label-keyed map (e.g. agent "x" {} +// becomes agents.x). It is exported so tests can verify the HCL conventions +// stay in sync with top-level keyed maps in the JSON schema. +func LabelKeyedMapOutKeys() map[string]bool { + out := make(map[string]bool, len(blockRules)) + for _, r := range blockRules { + if r.mode == modeMapByLabel { + out[r.outKey] = true + } + } + return out +} + // convertBody walks an HCL body, converting attributes into Go values and // blocks into nested map / list / yaml.MapSlice structures according to the // block rules. diff --git a/pkg/config/schema_test.go b/pkg/config/schema_test.go index ccd0788de..44f8e95b1 100644 --- a/pkg/config/schema_test.go +++ b/pkg/config/schema_test.go @@ -148,6 +148,31 @@ func TestSchemaMatchesGoTypes(t *testing.T) { } } +// TestHCLBlockRulesCoverSchemaMaps verifies that every top-level property in +// agent-schema.json shaped like a keyed map (an object with +// additionalProperties) has a matching modeMapByLabel rule registered in the +// HCL converter. Without this, adding a new top-level section to the schema +// would silently produce awkward HCL ergonomics (e.g. tool "x" {} mapping to +// tool.x instead of tools.x). +func TestHCLBlockRulesCoverSchemaMaps(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(schemaFile) + require.NoError(t, err) + + var root jsonSchema + require.NoError(t, json.Unmarshal(data, &root)) + + covered := hclconv.LabelKeyedMapOutKeys() + for name, prop := range root.Properties { + if prop.AdditionalProperties == nil { + continue + } + assert.True(t, covered[name], + "top-level schema map %q has no matching modeMapByLabel rule in pkg/config/hcl", name) + } +} + // jsonSchema mirrors the subset of JSON Schema we need for comparison. type jsonSchema struct { Properties map[string]jsonSchema `json:"properties,omitempty"` From c39db1865fa8c2d18172b8b15fb3b7134f3a07ac Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 30 Apr 2026 09:19:25 +0200 Subject: [PATCH 5/6] fix hcl review nits: tighten LooksLikeHCL, drop nil pollution --- pkg/config/hcl/hcl.go | 9 ++++++++- pkg/config/hcl/hcl_test.go | 20 +++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go index 2a7f430f7..41d4209c4 100644 --- a/pkg/config/hcl/hcl.go +++ b/pkg/config/hcl/hcl.go @@ -39,6 +39,11 @@ func LooksLikeHCL(data []byte) bool { if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { continue } + // A YAML mapping key (e.g. `agent "root":`) ends with a colon and + // must not be confused with an HCL block opener. + if strings.HasSuffix(trimmed, ":") { + return false + } for _, kw := range topLevelHCLKeywords { if strings.HasPrefix(trimmed, kw+" \"") || strings.HasPrefix(trimmed, kw+" {") { return true @@ -209,7 +214,9 @@ func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { for name, attr := range body.Attributes { val, attrDiags := convertExpr(attr.Expr) diags = append(diags, attrDiags...) - out[name] = val + if !attrDiags.HasErrors() { + out[name] = val + } } for _, block := range body.Blocks { diff --git a/pkg/config/hcl/hcl_test.go b/pkg/config/hcl/hcl_test.go index 1544bdbf6..54acdc0ad 100644 --- a/pkg/config/hcl/hcl_test.go +++ b/pkg/config/hcl/hcl_test.go @@ -1,6 +1,7 @@ package hcl import ( + "strings" "testing" "github.com/goccy/go-yaml" @@ -114,9 +115,9 @@ agent "reviewer" { require.NoError(t, err) got := string(out) - rootIdx := indexOf(got, "root:") - plannerIdx := indexOf(got, "planner:") - reviewerIdx := indexOf(got, "reviewer:") + rootIdx := strings.Index(got, "root:") + plannerIdx := strings.Index(got, "planner:") + reviewerIdx := strings.Index(got, "reviewer:") require.NotEqual(t, -1, rootIdx) require.NotEqual(t, -1, plannerIdx) @@ -231,6 +232,7 @@ func TestLooksLikeHCL(t *testing.T) { {"empty", "", false}, {"yaml", "agents:\n root:\n instruction: hi\n", false}, {"yaml with comment", "# a comment\nagents:\n root: {}\n", false}, + {"yaml quoted key", "agent \"root\":\n instruction: hi\n", false}, {"hcl with shebang", "#!/usr/bin/env docker agent run\n\nagent \"root\" {\n model = \"auto\"\n}\n", true}, {"hcl permissions block", "permissions {\n allow = [\"a\"]\n}\n", true}, {"hcl with line comment", "// a comment\nagent \"root\" {}\n", true}, @@ -244,15 +246,3 @@ func TestLooksLikeHCL(t *testing.T) { }) } } - -// indexOf returns the byte offset of the first occurrence of substr in s, -// or -1 if substr is not present. It avoids importing strings into the -// test file just for this helper. -func indexOf(s, substr string) int { - for i := 0; i+len(substr) <= len(s); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} From 4648ae3f1ce97b61bb98b89d1f8fe0fd90e2a3d7 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 30 Apr 2026 09:32:06 +0200 Subject: [PATCH 6/6] ci: whitelist hashicorp/hcl/v2 (MPL-2.0) for license check --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8634beff7..9bff71ee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: run: go install github.com/google/go-licenses@latest - name: Check licenses - run: go-licenses check . --allowed_licenses=Apache-2.0,MIT,BSD-3-Clause,BSD-2-Clause --ignore modernc.org/mathutil + run: go-licenses check . --allowed_licenses=Apache-2.0,MIT,BSD-3-Clause,BSD-2-Clause --ignore modernc.org/mathutil --ignore github.com/hashicorp/hcl/v2 build-image: if: github.event_name == 'pull_request'