feat: extend agent chat MCP tools for remote UAT evidence loops (#28233)

Extends the Agent-chat MCP tools so an unattended UAT evidence loop can
fetch artifacts, monitor long runs, and find prior runs without burning
model context.

## Backend

- New `chat_files_token` crypto key feature (migration 000571) with
rotator support and a dedicated signing keycache on coderd.
- `POST /api/experimental/chats/files/{file}/download-url`
(authenticated) mints a short-lived (5 min) signed URL and returns it
with `sha256`, `size_bytes`, `name`, `mime_type`, and `expires_at`.
- `GET /api/experimental/chats/files/{file}/download?token=` (no session
token) redeems the signed URL: verifies the JWS, requires the token's
`file_id` to match the path, and re-checks the minting user's RBAC
access live at redemption. Clients can `curl -o` artifacts with zero
credentials in the URL consumer.
- `ChatFileMetadata` gains `size_bytes` (via `octet_length`, no bytes
fetched).

## MCP tools (`codersdk/toolsdk`)

- `coder_download_chat_file`: by `file_id` or `chat_id`+`file_name`;
returns the signed URL plus checksum and size instead of base64.
- `coder_await_chat`: blocks (bounded `wait_secs`, 1-120) until a chat
leaves `running`/`interrupting`, using the existing watch stream with
subscribe-before-read.
- `coder_list_chats`: label, query, and limit filtering; chat
projections now include labels.
- `coder_get_chat_messages`: `after_id` forward cursor with
`next_after_id` (exact incremental reads), plus per-message `files`
metadata so artifact-bearing messages are identifiable.
- `coder_get_chat`: file listings now include `size_bytes` and
`created_at`.
- `coder_list_templates`: exposes `agents_allowed` for pre-flight
checks.

## Testing

- coderd: mint/redeem happy path with an unauthenticated client,
expired/tampered/file-mismatched tokens, auth still required on the
plain file endpoint, non-owner mint rejection.
- toolsdk: harness + integration coverage for all new/changed tools,
including signed-URL redemption with checksum verification,
forward-cursor exactness, await transition/timeout paths, and label
filtering.
- Remote dogfood UAT (dev.coder.com Coder Agent) passed all six
acceptance scenarios end to end over both MCP transports.

Note: `go test ./codersdk/toolsdk/` has a pre-existing goleak flake on
main (leaked `agentssh` non-PTY session goroutines from SSH exec tests;
reproduced 3/3 on clean `b4971bc49f1`). It is unrelated to this diff.

> Mux acted on Mike's behalf to create this PR.

<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
This commit is contained in:
Michael Suchacz
2026-08-18 19:15:30 +02:00
committed by GitHub
parent 7724ee281a
commit affeeaf9c8
30 changed files with 1840 additions and 91 deletions
+1
View File
@@ -1903,6 +1903,7 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
OrganizationID: row.OrganizationID,
Name: row.Name,
MimeType: row.Mimetype,
SizeBytes: row.SizeBytes,
CreatedAt: row.CreatedAt,
})
}
+2
View File
@@ -848,6 +848,7 @@ func TestChat_FileMetadataConversion(t *testing.T) {
OrganizationID: orgID,
Name: "screenshot.png",
Mimetype: "image/png",
SizeBytes: 1234,
CreatedAt: now,
},
}
@@ -861,6 +862,7 @@ func TestChat_FileMetadataConversion(t *testing.T) {
require.Equal(t, orgID, f.OrganizationID, "OrganizationID must be mapped from DB row")
require.Equal(t, "screenshot.png", f.Name)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, int64(1234), f.SizeBytes)
require.Equal(t, now, f.CreatedAt)
// Verify JSON serialization uses snake_case for mime_type.
+1
View File
@@ -945,6 +945,7 @@ func (s *MethodTestSuite) TestChats() {
ID: file.ID,
Name: file.Name,
Mimetype: file.Mimetype,
SizeBytes: int64(len(file.Data)),
CreatedAt: file.CreatedAt,
OwnerID: file.OwnerID,
OrganizationID: file.OrganizationID,
+2
View File
@@ -2229,6 +2229,8 @@ func newCryptoKeySecret(feature database.CryptoKeyFeature) (string, error) {
return generateCryptoKey(64)
case database.CryptoKeyFeatureTailnetResume:
return generateCryptoKey(64)
case database.CryptoKeyFeatureChatFilesToken:
return generateCryptoKey(64)
case database.CryptoKeyFeatureNATSCA:
return generateCACryptoKeySecret()
}
+2 -1
View File
@@ -398,7 +398,8 @@ CREATE TYPE crypto_key_feature AS ENUM (
'workspace_apps_api_key',
'oidc_convert',
'tailnet_resume',
'nats_ca'
'nats_ca',
'chat_files_token'
);
CREATE TYPE display_app AS ENUM (
@@ -0,0 +1 @@
-- PostgreSQL does not support removing enum values safely.
@@ -0,0 +1 @@
ALTER TYPE crypto_key_feature ADD VALUE IF NOT EXISTS 'chat_files_token';
+4 -1
View File
@@ -2043,6 +2043,7 @@ const (
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
CryptoKeyFeatureNATSCA CryptoKeyFeature = "nats_ca"
CryptoKeyFeatureChatFilesToken CryptoKeyFeature = "chat_files_token"
)
func (e *CryptoKeyFeature) Scan(src interface{}) error {
@@ -2086,7 +2087,8 @@ func (e CryptoKeyFeature) Valid() bool {
CryptoKeyFeatureWorkspaceAppsAPIKey,
CryptoKeyFeatureOIDCConvert,
CryptoKeyFeatureTailnetResume,
CryptoKeyFeatureNATSCA:
CryptoKeyFeatureNATSCA,
CryptoKeyFeatureChatFilesToken:
return true
}
return false
@@ -2099,6 +2101,7 @@ func AllCryptoKeyFeatureValues() []CryptoKeyFeature {
CryptoKeyFeatureOIDCConvert,
CryptoKeyFeatureTailnetResume,
CryptoKeyFeatureNATSCA,
CryptoKeyFeatureChatFilesToken,
}
}
+4 -1
View File
@@ -5930,7 +5930,8 @@ func (q *sqlQuerier) GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetCh
}
const getChatFileMetadataByChatID = `-- name: GetChatFileMetadataByChatID :many
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at,
octet_length(cf.data)::bigint AS size_bytes
FROM chat_files cf
JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = $1::uuid
@@ -5944,6 +5945,7 @@ type GetChatFileMetadataByChatIDRow struct {
Name string `db:"name" json:"name"`
Mimetype string `db:"mimetype" json:"mimetype"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
}
// GetChatFileMetadataByChatID returns lightweight file metadata for
@@ -5965,6 +5967,7 @@ func (q *sqlQuerier) GetChatFileMetadataByChatID(ctx context.Context, chatID uui
&i.Name,
&i.Mimetype,
&i.CreatedAt,
&i.SizeBytes,
); err != nil {
return nil, err
}
+2 -1
View File
@@ -21,7 +21,8 @@ WHERE id = ANY(@ids::uuid[]);
-- GetChatFileMetadataByChatID returns lightweight file metadata for
-- all files linked to a chat. The data column is excluded to avoid
-- loading file content.
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at,
octet_length(cf.data)::bigint AS size_bytes
FROM chat_files cf
JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = @chat_id::uuid