From ded9a4a60dc39d5d6e66acebd15b768e1bb3700a Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Thu, 24 Sep 2026 20:11:34 +0500 Subject: [PATCH] feat(agent-relay-claude-code): support Anthropic-managed git Add use_anthropic_git_proxy, which authenticates clones server-side so the workspace holds no git credentials. A session a person created uses their GitHub OAuth token; one a bot or agent created uses the organization's GitHub App installation token. That second path is the only git auth that works when the workspace owner is a service account: it has login_type "none", can never sign in, and so can never complete the OAuth flow a coder_external_auth block depends on. Agent Relay creates each workspace as the resolved session owner, so this is reachable today rather than hypothetical. The module takes no position on when to enable it. The flag serves human and bot sessions alike, so a template can gate it on the owner's login_type or turn it on everywhere; both are documented. Pair it with configure_git, defaulting to follow it. The proxy replaces the HOME git config with credentials only, so enabling it alone leaves no identity and commits fail. Set configure_git explicitly to override: false for an image that keeps its identity in /etc/gitconfig, which the proxy does not touch, or true to sign commits without the proxy. --- .../modules/agent-relay-claude-code/README.md | 44 +++++++++++++ .../modules/agent-relay-claude-code/main.tf | 28 +++++++++ .../agent-relay-claude-code/main.tftest.hcl | 63 +++++++++++++++++++ .../agent-relay-claude-code/start.sh.tftpl | 6 ++ 4 files changed, 141 insertions(+) diff --git a/registry/coder/modules/agent-relay-claude-code/README.md b/registry/coder/modules/agent-relay-claude-code/README.md index 4fe13edb5..1d02ba818 100644 --- a/registry/coder/modules/agent-relay-claude-code/README.md +++ b/registry/coder/modules/agent-relay-claude-code/README.md @@ -49,6 +49,8 @@ when to reap the workspace. ## Requirements +- A Coder deployment running 2.37 or newer, which Agent Relay refuses to + start against otherwise. - The `claude` CLI must be in the workspace. `install_cli` (default `true`) downloads it at start only when it is not already on PATH; bake it into the image for the fastest start. `cli_binary` overrides the path. @@ -146,6 +148,48 @@ rather than reporting `working` forever. Renaming the `agent_relay_status` key breaks reaping. +## Git authentication + +`use_anthropic_git_proxy` authenticates clones server-side, so the +workspace holds no git credentials. A session a person created uses their +GitHub OAuth token; one a bot or agent created uses the organization's +GitHub App installation token. + +```tf +module "claude_code_runner" { + # ... + use_anthropic_git_proxy = true +} +``` + +That second path is the only git auth that works when the workspace owner +is a **service account**, which you detect with `login_type = "none"`. +Such an owner can never sign in, so it can never complete the OAuth flow +a `coder_external_auth` block depends on. Gate on it if you want the +proxy only there; the flag serves human sessions equally well, so +enabling it everywhere is also fine: + +```tf +# login_type "none" means a service account: it cannot sign in, so it +# cannot link GitHub. +use_anthropic_git_proxy = data.coder_workspace_owner.me.login_type == "none" +``` + +`configure_git` follows it by default, setting the identity to +`Claude ` with signed commits. The proxy replaces +the HOME git config with credentials only, so without this there is no +identity and commits fail. Override either way: + +```tf +configure_git = false # image keeps its identity in /etc/gitconfig +configure_git = true # sign as Claude without the proxy +``` + +**The proxy rewrites the runner account's git config**, deleting +`~/.gitconfig`, the `GIT_CONFIG_GLOBAL` target and all of +`$XDG_CONFIG_HOME/git` at startup and before every session, with no +backup. Do not enable it on a workspace a person also works in. + ## Graceful shutdown The start step detaches the supervisor with `setsid`, so it lives in its own diff --git a/registry/coder/modules/agent-relay-claude-code/main.tf b/registry/coder/modules/agent-relay-claude-code/main.tf index 6138640db..3e13d0639 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tf +++ b/registry/coder/modules/agent-relay-claude-code/main.tf @@ -113,6 +113,28 @@ variable "client_label" { default = "" } +variable "configure_git" { + description = <<-EOT + Set the global git identity to Claude and sign commits through Anthropic's signing service (the CLI's --configure-git). + + Defaults to following use_anthropic_git_proxy: that flag replaces the HOME git config with credentials only, so the proxy alone leaves no identity and commits fail. Set false if your image keeps an identity in /etc/gitconfig, which the proxy does not touch; set true to sign commits without the proxy. + EOT + type = bool + default = null +} + +variable "use_anthropic_git_proxy" { + description = <<-EOT + Authenticate clones server-side instead of holding git credentials in the workspace (the CLI's --use-anthropic-git-proxy). A session a person created uses their GitHub OAuth token; one a bot or agent created uses the organization's GitHub App installation token. + + That second path is the only one that works when the workspace owner is a service account, which login_type "none" indicates: it can never sign in, so it can never complete a coder_external_auth flow. + + DESTRUCTIVE: at startup and before every session the runner deletes ~/.gitconfig, the GIT_CONFIG_GLOBAL target and all of $XDG_CONFIG_HOME/git, with no backup. That isolation is the point for an ephemeral single-session workspace. Do not enable it on a workspace a person also works in, and expect it to override any identity a git-config module wrote. + EOT + type = bool + default = false +} + variable "serving_log_pattern" { type = string default = "Picked up session" @@ -231,6 +253,10 @@ locals { # tree so one directory holds everything a debugger needs. module_directory = "$HOME/.coder-modules/coder/agent-relay-claude-code" + # The proxy restores credentials but no identity, so the two travel + # together unless a template says otherwise. + configure_git = var.configure_git != null ? var.configure_git : var.use_anthropic_git_proxy + # coder-utils prefixes its own steps with this; the stop step is a # plain coder_script, so it joins the same naming by hand. display_name_prefix = "Claude Code runner" @@ -251,6 +277,8 @@ locals { exit_if_unused_min = var.exit_if_unused_min drain_wait_sec = var.drain_wait_sec push_outcome_on_release = var.push_outcome_on_release + use_anthropic_git_proxy = var.use_anthropic_git_proxy + configure_git = local.configure_git # Free-form text: base64 so a label with quotes or spaces can never # become shell in the supervisor script. client_label = base64encode(local.client_label) diff --git a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl index 62637b9e9..a9eb4d799 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl @@ -321,6 +321,13 @@ run "graceful_shutdown_defaults" { condition = !strcontains(local.start_script, "--push-outcome-on-release") && !strcontains(local.start_script, "--drain-wait-sec") error_message = "both optional runner behaviours are off by default" } + + # It rewrites the account's HOME git config before every session, so it + # is never on unless the template asked for it. + assert { + condition = !strcontains(local.start_script, "--use-anthropic-git-proxy") && !strcontains(local.start_script, "--configure-git") + error_message = "Anthropic-managed git and the Claude git identity must both be opt-in" + } } run "drain_wait_enabled" { @@ -423,3 +430,59 @@ run "stop_script_waits_for_the_recorded_exit" { error_message = "the stop script must wait for the supervisor to record the exit, not just for the runner to go" } } + +run "anthropic_git_proxy_enabled" { + command = plan + + variables { + use_anthropic_git_proxy = true + } + + assert { + condition = strcontains(local.start_script, "--use-anthropic-git-proxy") + error_message = "use_anthropic_git_proxy must reach the runner as a flag" + } + + # The proxy leaves no identity behind, so the two travel together + # unless a template says otherwise. + assert { + condition = strcontains(local.start_script, "--configure-git") + error_message = "the Claude git identity must follow the proxy by default" + } + + # It authenticates clones server-side; it does not change what the + # runner has to do on the way out. + assert { + condition = output.shutdown_grace_seconds == 105 + error_message = "Anthropic-managed git must not change the shutdown budget" + } +} + +run "git_identity_overrides_the_proxy_default" { + command = plan + + variables { + use_anthropic_git_proxy = true + configure_git = false + } + + # For an image that supplies its identity in /etc/gitconfig, which the + # proxy does not delete. + assert { + condition = strcontains(local.start_script, "--use-anthropic-git-proxy") && !strcontains(local.start_script, "--configure-git") + error_message = "configure_git = false must win over the proxy default" + } +} + +run "git_identity_without_the_proxy" { + command = plan + + variables { + configure_git = true + } + + assert { + condition = strcontains(local.start_script, "--configure-git") && !strcontains(local.start_script, "--use-anthropic-git-proxy") + error_message = "signing commits must not require Anthropic-managed git" + } +} diff --git a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl index 13dda0558..e2a35ad2c 100644 --- a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl @@ -104,6 +104,12 @@ ${cli_binary} self-hosted-runner \\ %{ endif ~} %{ if push_outcome_on_release ~} --push-outcome-on-release \\ +%{ endif ~} +%{ if use_anthropic_git_proxy ~} + --use-anthropic-git-proxy \\ +%{ endif ~} +%{ if configure_git ~} + --configure-git \\ %{ endif ~} --client-label "\$client_label" \\ --exec-path "$wrapper" >"$log_file" 2>&1 &