feat: support multiple agents with shared instance-identity auth (#24325)

> This PR was authored by Mux on behalf of Mike.

## Summary

Adds support for multiple peer root workspace agents sharing the same
`auth_instance_id`, so AWS, Azure, and GCP instance-identity auth can
issue the correct session token for a selected agent instead of assuming
a
single root agent per instance.

## Problem

When a Terraform template attaches two or more `coder_agent` resources
(with `auth = "aws-instance-identity"`) to a single compute instance,
every agent shares the same cloud instance ID. The existing singular
lookup picks whichever agent was created most recently, silently
ignoring
the others.

## Solution

Introduce an optional pre-auth agent selector (`CODER_AGENT_NAME`) and
make the server-side lookup ambiguity-aware.

**Database layer:**
- `GetWorkspaceAgentsByInstanceID` (`:many`): returns all matching root
  agents for an instance ID.
- `GetWorkspaceAgentByInstanceIDAndName` (`:one`): returns the named
root
  agent for disambiguation.

**SDK and CLI:**
- `agent_name` field added to AWS, Azure, and GCP request structs
  (`omitempty` for backward compatibility).
- `CODER_AGENT_NAME` env var and `--agent-name` flag wired into the
agent
  bootstrap before instance-identity auth runs.

**Server handler (`handleAuthInstanceID`):**
- When `agent_name` is present: direct lookup by (instance ID, name).
- When absent: legacy lookup, then resource-scoped ambiguity check.
  Returns 409 with available agent names if multiple root agents match.
- Whitespace-only names are trimmed and treated as unspecified.
- Sub-agents remain excluded (`parent_id IS NULL` filter).

**Verification template:**
- `examples/templates/aws-multi-agent/` provisions one EC2 instance with
  two agents (`main` and `dev`), both using instance-identity auth with
  `CODER_AGENT_NAME` set in the cloud-init user data.

## Backward compatibility

Existing single-agent deployments work unchanged. The `agent_name` field
is optional with `omitempty`, and the unnamed path preserves today's
behavior when only one root agent matches.
This commit is contained in:
Michael Suchacz
2026-04-16 13:59:09 +02:00
committed by GitHub
parent 1cf0354f72
commit e5707a13d6
29 changed files with 1563 additions and 286 deletions
+17 -3
View File
@@ -86,6 +86,7 @@ const (
envAgentTokenFile = "CODER_AGENT_TOKEN_FILE"
envAgentURL = "CODER_AGENT_URL"
envAgentAuth = "CODER_AGENT_AUTH"
envAgentName = "CODER_AGENT_NAME"
envURL = "CODER_URL"
)
@@ -789,6 +790,7 @@ type AgentAuth struct {
agentTokenFile string
agentURL url.URL
agentAuth string
agentName string
}
func (a *AgentAuth) AttachOptions(cmd *serpent.Command, hidden bool) {
@@ -821,6 +823,13 @@ func (a *AgentAuth) AttachOptions(cmd *serpent.Command, hidden bool) {
Default: "token",
Value: serpent.StringOf(&a.agentAuth),
Hidden: hidden,
}, serpent.Option{
Name: "Agent Name",
Description: "The name of the agent to authenticate as (only applicable for instance identity).",
Flag: "agent-name",
Env: envAgentName,
Value: serpent.StringOf(&a.agentName),
Hidden: hidden,
})
}
@@ -832,6 +841,11 @@ func (a *AgentAuth) CreateClient() (*agentsdk.Client, error) {
return nil, xerrors.Errorf("%s must be set", envAgentURL)
}
var iiOpts []agentsdk.InstanceIdentityOption
if a.agentName != "" {
iiOpts = append(iiOpts, agentsdk.WithInstanceIdentityAgentName(a.agentName))
}
switch a.agentAuth {
case "token":
token := a.agentToken
@@ -850,11 +864,11 @@ func (a *AgentAuth) CreateClient() (*agentsdk.Client, error) {
}
return agentsdk.New(&a.agentURL, agentsdk.WithFixedToken(token)), nil
case "google-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithGoogleInstanceIdentity("", nil)), nil
return agentsdk.New(&a.agentURL, agentsdk.WithGoogleInstanceIdentity("", nil, iiOpts...)), nil
case "aws-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithAWSInstanceIdentity()), nil
return agentsdk.New(&a.agentURL, agentsdk.WithAWSInstanceIdentity(iiOpts...)), nil
case "azure-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithAzureInstanceIdentity()), nil
return agentsdk.New(&a.agentURL, agentsdk.WithAzureInstanceIdentity(iiOpts...)), nil
default:
return nil, xerrors.Errorf("unknown agent auth type: %s", a.agentAuth)
}
+63
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"runtime"
"strings"
"sync/atomic"
@@ -346,6 +347,68 @@ func TestCreateAgentClient_Azure(t *testing.T) {
require.IsType(t, &agentsdk.AzureSessionTokenExchanger{}, provider.TokenExchanger)
}
func TestCreateAgentClient_GoogleAgentName(t *testing.T) {
t.Parallel()
client := createAgentWithFlags(t,
"--auth", "google-instance-identity",
"--agent-url", "http://coder.fake",
"--agent-name", "google-agent")
requireInstanceIdentityAgentName(t, client, &agentsdk.GoogleSessionTokenExchanger{}, "google-agent")
}
func TestCreateAgentClient_AWSAgentName(t *testing.T) {
t.Parallel()
client := createAgentWithFlags(t,
"--auth", "aws-instance-identity",
"--agent-url", "http://coder.fake",
"--agent-name", "aws-agent")
requireInstanceIdentityAgentName(t, client, &agentsdk.AWSSessionTokenExchanger{}, "aws-agent")
}
func TestCreateAgentClient_AzureAgentName(t *testing.T) {
t.Parallel()
client := createAgentWithFlags(t,
"--auth", "azure-instance-identity",
"--agent-url", "http://coder.fake",
"--agent-name", "azure-agent")
requireInstanceIdentityAgentName(t, client, &agentsdk.AzureSessionTokenExchanger{}, "azure-agent")
}
func TestCreateAgentClient_GoogleAgentNameEnv(t *testing.T) {
t.Parallel()
r := &cli.RootCmd{}
var client *agentsdk.Client
subCmd := agentClientCommand(&client)
cmd, err := r.Command([]*serpent.Command{subCmd})
require.NoError(t, err)
inv, _ := clitest.NewWithCommand(t, cmd,
"agent-client",
"--auth", "google-instance-identity",
"--agent-url", "http://coder.fake")
inv.Environ.Set("CODER_AGENT_NAME", "env-agent")
err = inv.Run()
require.NoError(t, err)
require.NotNil(t, client)
requireInstanceIdentityAgentName(t, client, &agentsdk.GoogleSessionTokenExchanger{}, "env-agent")
}
func requireInstanceIdentityAgentName(t *testing.T, client *agentsdk.Client, expectedExchanger any, want string) {
t.Helper()
provider, ok := client.RefreshableSessionTokenProvider.(*agentsdk.InstanceIdentitySessionTokenProvider)
require.True(t, ok)
require.NotNil(t, provider.TokenExchanger)
require.IsType(t, expectedExchanger, provider.TokenExchanger)
agentNameField := reflect.ValueOf(provider.TokenExchanger).Elem().FieldByName("agentName")
require.True(t, agentNameField.IsValid())
require.Equal(t, want, agentNameField.String())
}
func createAgentWithFlags(t *testing.T, flags ...string) *agentsdk.Client {
t.Helper()
r := &cli.RootCmd{}
+4
View File
@@ -9,6 +9,10 @@ OPTIONS:
--auth string, $CODER_AGENT_AUTH (default: token)
Specify the authentication type to use for the agent.
--agent-name string, $CODER_AGENT_NAME
The name of the agent to authenticate as (only applicable for instance
identity).
--agent-token string, $CODER_AGENT_TOKEN
An agent authentication token.
@@ -28,6 +28,10 @@ OPTIONS:
--auth string, $CODER_AGENT_AUTH (default: token)
Specify the authentication type to use for the agent.
--agent-name string, $CODER_AGENT_NAME
The name of the agent to authenticate as (only applicable for instance
identity).
--agent-token string, $CODER_AGENT_TOKEN
An agent authentication token.