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
+115
View File
@@ -374,6 +374,91 @@ const docTemplate = `{
]
}
},
"/api/experimental/chats/files/{file}/download": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"application/pdf"
],
"tags": [
"Chats"
],
"summary": "Download chat file with signed token",
"operationId": "download-chat-file",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "File ID",
"name": "file",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Signed download token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/files/{file}/download-url": {
"post": {
"description": "Experimental: this endpoint is subject to change.",
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Create chat file download URL",
"operationId": "create-chat-file-download-url",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "File ID",
"name": "file",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatFileDownloadURLResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/models": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -17906,6 +17991,31 @@ const docTemplate = `{
"ChatErrorKindHookDenied"
]
},
"codersdk.ChatFileDownloadURLResponse": {
"type": "object",
"properties": {
"expires_at": {
"type": "string",
"format": "date-time"
},
"mime_type": {
"type": "string"
},
"name": {
"type": "string"
},
"sha256": {
"type": "string"
},
"size_bytes": {
"type": "integer"
},
"url": {
"type": "string",
"format": "uri"
}
}
},
"codersdk.ChatFileMetadata": {
"type": "object",
"properties": {
@@ -17930,6 +18040,9 @@ const docTemplate = `{
"owner_id": {
"type": "string",
"format": "uuid"
},
"size_bytes": {
"type": "integer"
}
}
},
@@ -19858,6 +19971,7 @@ const docTemplate = `{
"workspace_apps_api_key",
"workspace_apps_token",
"oidc_convert",
"chat_files_token",
"tailnet_resume",
"nats_ca"
],
@@ -19865,6 +19979,7 @@ const docTemplate = `{
"CryptoKeyFeatureWorkspaceAppsAPIKey",
"CryptoKeyFeatureWorkspaceAppsToken",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureChatFilesToken",
"CryptoKeyFeatureTailnetResume",
"CryptoKeyFeatureNATSCA"
]
+109
View File
@@ -327,6 +327,85 @@
]
}
},
"/api/experimental/chats/files/{file}/download": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"application/pdf"
],
"tags": ["Chats"],
"summary": "Download chat file with signed token",
"operationId": "download-chat-file",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "File ID",
"name": "file",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Signed download token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/files/{file}/download-url": {
"post": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Create chat file download URL",
"operationId": "create-chat-file-download-url",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "File ID",
"name": "file",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatFileDownloadURLResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/models": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -16120,6 +16199,31 @@
"ChatErrorKindHookDenied"
]
},
"codersdk.ChatFileDownloadURLResponse": {
"type": "object",
"properties": {
"expires_at": {
"type": "string",
"format": "date-time"
},
"mime_type": {
"type": "string"
},
"name": {
"type": "string"
},
"sha256": {
"type": "string"
},
"size_bytes": {
"type": "integer"
},
"url": {
"type": "string",
"format": "uri"
}
}
},
"codersdk.ChatFileMetadata": {
"type": "object",
"properties": {
@@ -16144,6 +16248,9 @@
"owner_id": {
"type": "string",
"format": "uuid"
},
"size_bytes": {
"type": "integer"
}
}
},
@@ -17987,6 +18094,7 @@
"workspace_apps_api_key",
"workspace_apps_token",
"oidc_convert",
"chat_files_token",
"tailnet_resume",
"nats_ca"
],
@@ -17994,6 +18102,7 @@
"CryptoKeyFeatureWorkspaceAppsAPIKey",
"CryptoKeyFeatureWorkspaceAppsToken",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureChatFilesToken",
"CryptoKeyFeatureTailnetResume",
"CryptoKeyFeatureNATSCA"
]
+18
View File
@@ -325,6 +325,7 @@ type Options struct {
AppSigningKeyCache cryptokeys.SigningKeycache
AppEncryptionKeyCache cryptokeys.EncryptionKeycache
OIDCConvertKeyCache cryptokeys.SigningKeycache
ChatFileTokenKeyCache cryptokeys.SigningKeycache
// NATSCACache serves the NATS cluster mTLS CA via the generic signing key
// cache for the nats_ca feature. SigningKey returns the active CA
// (a *NATSCA); VerifyingKey returns a specific CA by sequence. The key
@@ -595,6 +596,17 @@ func New(options *Options) *API {
}
}
if options.ChatFileTokenKeyCache == nil {
options.ChatFileTokenKeyCache, err = cryptokeys.NewSigningCache(ctx,
options.Logger.Named("chat_file_token_keycache"),
fetcher,
codersdk.CryptoKeyFeatureChatFilesToken,
)
if err != nil {
options.Logger.Fatal(ctx, "failed to properly instantiate chat file token signing cache", slog.Error(err))
}
}
if options.AppSigningKeyCache == nil {
options.AppSigningKeyCache, err = cryptokeys.NewSigningCache(ctx,
options.Logger.Named("app_signing_keycache"),
@@ -1362,6 +1374,10 @@ func New(options *Options) *API {
r.Delete("/", api.deleteUserAIProviderKey)
})
})
r.Group(func(r chi.Router) {
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
r.Get("/chats/files/{file}/download", api.downloadChatFile)
})
r.Route("/chats", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
@@ -1374,6 +1390,7 @@ func New(options *Options) *API {
r.Route("/files", func(r chi.Router) {
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
r.Post("/", api.postChatFile)
r.Post("/{file}/download-url", api.postChatFileDownloadURL)
r.Get("/{file}", api.chatFileByID)
})
r.Route("/config", func(r chi.Router) {
@@ -2496,6 +2513,7 @@ func (api *API) Close() error {
}
_ = api.NetworkTelemetryBatcher.Close()
_ = api.OIDCConvertKeyCache.Close()
_ = api.ChatFileTokenKeyCache.Close()
_ = api.AppSigningKeyCache.Close()
_ = api.AppEncryptionKeyCache.Close()
if api.NATSCACache != nil {
+2
View File
@@ -204,6 +204,7 @@ type Options struct {
NotificationsEnqueuer notifications.Enqueuer
APIKeyEncryptionCache cryptokeys.EncryptionKeycache
OIDCConvertKeyCache cryptokeys.SigningKeycache
ChatFileTokenKeyCache cryptokeys.SigningKeycache
Clock quartz.Clock
Acquirer *provisionerdserver.Acquirer
TelemetryReporter telemetry.Reporter
@@ -693,6 +694,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
Acquirer: options.Acquirer,
AppEncryptionKeyCache: options.APIKeyEncryptionCache,
OIDCConvertKeyCache: options.OIDCConvertKeyCache,
ChatFileTokenKeyCache: options.ChatFileTokenKeyCache,
ProvisionerdServerMetrics: options.ProvisionerdServerMetrics,
WorkspaceBuilderMetrics: options.WorkspaceBuilderMetrics,
}
+1 -1
View File
@@ -233,7 +233,7 @@ func isEncryptionKeyFeature(feature codersdk.CryptoKeyFeature) bool {
func isSigningKeyFeature(feature codersdk.CryptoKeyFeature) bool {
switch feature {
case codersdk.CryptoKeyFeatureTailnetResume, codersdk.CryptoKeyFeatureOIDCConvert, codersdk.CryptoKeyFeatureWorkspaceAppsToken, codersdk.CryptoKeyFeatureNATSCA:
case codersdk.CryptoKeyFeatureTailnetResume, codersdk.CryptoKeyFeatureOIDCConvert, codersdk.CryptoKeyFeatureChatFilesToken, codersdk.CryptoKeyFeatureWorkspaceAppsToken, codersdk.CryptoKeyFeatureNATSCA:
return true
default:
return false
+8
View File
@@ -20,6 +20,9 @@ import (
const (
WorkspaceAppsTokenDuration = time.Minute
OIDCConvertTokenDuration = time.Minute * 5
// ChatFilesTokenDuration is also the lifetime of minted chat file
// download URLs, keeping key retention aligned with token expiry.
ChatFilesTokenDuration = time.Minute * 5
TailnetResumeTokenDuration = time.Hour * 24
// NATSCAOverlap is how long a NATS cluster CA certificate stays valid past
// the end of its active-signing window (startsAt + keyDuration). The next CA
@@ -46,6 +49,7 @@ var defaultRotatedFeatures = []database.CryptoKeyFeature{
database.CryptoKeyFeatureWorkspaceAppsToken,
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
database.CryptoKeyFeatureOIDCConvert,
database.CryptoKeyFeatureChatFilesToken,
database.CryptoKeyFeatureTailnetResume,
}
@@ -273,6 +277,8 @@ func generateNewSecret(feature database.CryptoKeyFeature, startsAt time.Time, ke
return generateKey(64)
case database.CryptoKeyFeatureOIDCConvert:
return generateKey(64)
case database.CryptoKeyFeatureChatFilesToken:
return generateKey(64)
case database.CryptoKeyFeatureTailnetResume:
return generateKey(64)
case database.CryptoKeyFeatureNATSCA:
@@ -298,6 +304,8 @@ func tokenDuration(feature database.CryptoKeyFeature) time.Duration {
return WorkspaceAppsTokenDuration
case database.CryptoKeyFeatureOIDCConvert:
return OIDCConvertTokenDuration
case database.CryptoKeyFeatureChatFilesToken:
return ChatFilesTokenDuration
case database.CryptoKeyFeatureTailnetResume:
return TailnetResumeTokenDuration
case database.CryptoKeyFeatureNATSCA:
+6 -1
View File
@@ -513,7 +513,7 @@ func Test_rotateKeys(t *testing.T) {
keys, err := db.GetCryptoKeys(ctx)
require.NoError(t, err)
require.Len(t, keys, 5)
require.Len(t, keys, 6)
kbf := keysByFeature(keys, defaultRotatedFeatures)
@@ -525,13 +525,16 @@ func Test_rotateKeys(t *testing.T) {
// caused a key to be inserted.
require.Len(t, kbf[database.CryptoKeyFeatureTailnetResume], 1)
require.Len(t, kbf[database.CryptoKeyFeatureWorkspaceAppsToken], 1)
require.Len(t, kbf[database.CryptoKeyFeatureChatFilesToken], 1)
oidcKey := kbf[database.CryptoKeyFeatureOIDCConvert][0]
tailnetKey := kbf[database.CryptoKeyFeatureTailnetResume][0]
appTokenKey := kbf[database.CryptoKeyFeatureWorkspaceAppsToken][0]
chatFileTokenKey := kbf[database.CryptoKeyFeatureChatFilesToken][0]
requireKey(t, oidcKey, database.CryptoKeyFeatureOIDCConvert, now, nullTime, validKey.Sequence)
requireKey(t, tailnetKey, database.CryptoKeyFeatureTailnetResume, now, nullTime, deletedKey.Sequence+1)
requireKey(t, appTokenKey, database.CryptoKeyFeatureWorkspaceAppsToken, now, nullTime, 1)
requireKey(t, chatFileTokenKey, database.CryptoKeyFeatureChatFilesToken, now, nullTime, 1)
newKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][0]
oldKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][1]
if newKey.Sequence == rotatedKey.Sequence {
@@ -705,6 +708,8 @@ func requireKey(t *testing.T, key database.CryptoKey, feature database.CryptoKey
switch key.Feature {
case database.CryptoKeyFeatureOIDCConvert:
require.Len(t, secret, 64)
case database.CryptoKeyFeatureChatFilesToken:
require.Len(t, secret, 64)
case database.CryptoKeyFeatureWorkspaceAppsToken:
require.Len(t, secret, 64)
case database.CryptoKeyFeatureWorkspaceAppsAPIKey:
+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
+153 -1
View File
@@ -3,7 +3,9 @@ package coderd
import (
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -11,6 +13,7 @@ import (
"mime"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
@@ -19,6 +22,7 @@ import (
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
@@ -26,6 +30,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
@@ -36,6 +41,7 @@ import (
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -5944,6 +5950,145 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
})
}
// ChatFileDownloadClaims are the signed claims embedded in a chat file
// download URL token.
type ChatFileDownloadClaims struct {
jwtutils.RegisteredClaims
FileID uuid.UUID `json:"file_id"`
UserID uuid.UUID `json:"user_id"`
}
func (c ChatFileDownloadClaims) Validate(expected jwt.Expected) error {
if c.FileID == uuid.Nil {
return xerrors.New("file ID is required")
}
if c.UserID == uuid.Nil {
return xerrors.New("user ID is required")
}
return c.RegisteredClaims.Validate(expected)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Create chat file download URL
// @ID create-chat-file-download-url
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param file path string true "File ID" format(uuid)
// @Success 200 {object} codersdk.ChatFileDownloadURLResponse
// @Router /api/experimental/chats/files/{file}/download-url [post]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
func (api *API) postChatFileDownloadURL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file ID.",
})
return
}
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
Detail: err.Error(),
})
return
}
now := time.Now()
// Truncate to whole seconds so the advertised expiry matches the JWT
// exp claim, which jwt.NewNumericDate stores at second precision.
expiresAt := now.Add(cryptokeys.ChatFilesTokenDuration).Truncate(time.Second)
claims := ChatFileDownloadClaims{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(now),
},
FileID: fileID,
UserID: httpmw.APIKey(r).UserID,
}
token, err := jwtutils.Sign(ctx, api.ChatFileTokenKeyCache, claims)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat file download URL.",
Detail: err.Error(),
})
return
}
downloadURL := api.AccessURL.JoinPath("api", "experimental", "chats", "files", fileID.String(), "download")
downloadURL.RawQuery = url.Values{"token": {token}}.Encode()
digest := sha256.Sum256(chatFile.Data)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatFileDownloadURLResponse{
URL: downloadURL.String(),
ExpiresAt: expiresAt,
SHA256: hex.EncodeToString(digest[:]),
SizeBytes: int64(len(chatFile.Data)),
Name: chatFile.Name,
MimeType: chatFile.Mimetype,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Download chat file with signed token
// @ID download-chat-file
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Param token query string true "Signed download token"
// @Success 200
// @Router /api/experimental/chats/files/{file}/download [get]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.ResourceNotFound(rw)
return
}
var claims ChatFileDownloadClaims
if err := jwtutils.Verify(ctx, api.ChatFileTokenKeyCache, r.URL.Query().Get("token"), &claims); err != nil || claims.FileID != fileID {
httpapi.ResourceNotFound(rw)
return
}
subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll)
if err != nil || status != database.UserStatusActive {
httpapi.ResourceNotFound(rw)
return
}
chatFile, err := api.Database.GetChatFileByID(dbauthz.As(ctx, subject), fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
// This endpoint is reachable without a session token, so internal
// error details stay in the logs rather than the response body.
api.Logger.Error(ctx, "failed to get chat file for signed download", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
})
return
}
// Never let private caches replay a signed URL past its expiry;
// revocation is re-checked only when the request reaches coderd.
rw.Header().Set("Cache-Control", "no-store")
api.serveChatFile(ctx, rw, chatFile)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get chat file
@@ -5980,6 +6125,14 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
return
}
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
api.serveChatFile(r.Context(), rw, chatFile)
}
// serveChatFile writes the file body and content headers; callers set
// Cache-Control because signed and session-authenticated downloads have
// different caching requirements.
func (api *API) serveChatFile(ctx context.Context, rw http.ResponseWriter, chatFile database.ChatFile) {
rw.Header().Set("Content-Type", chatFile.Mimetype)
disposition := "attachment"
if chatfiles.IsInlineRenderableStoredMediaType(chatFile.Mimetype) {
@@ -5990,7 +6143,6 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
} else {
rw.Header().Set("Content-Disposition", disposition)
}
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
rw.Header().Set("Content-Length", strconv.Itoa(len(chatFile.Data)))
rw.WriteHeader(http.StatusOK)
if _, err := rw.Write(chatFile.Data); err != nil {
+213
View File
@@ -3,6 +3,7 @@ package coderd_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
stderrors "errors"
@@ -10,6 +11,7 @@ import (
"io"
"mime"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
@@ -18,6 +20,7 @@ import (
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sqlc-dev/pqtype"
@@ -41,6 +44,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -8071,6 +8075,7 @@ func TestChatMessageWithFiles(t *testing.T) {
require.Equal(t, firstUser.UserID, f.OwnerID)
require.NotEqual(t, uuid.Nil, f.OrganizationID)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, int64(len(pngData)), f.SizeBytes)
require.Equal(t, "test.png", f.Name)
require.NotZero(t, f.CreatedAt)
})
@@ -11272,6 +11277,214 @@ func TestGetChatFile(t *testing.T) {
})
}
func TestChatFileDownloadURL(t *testing.T) {
t.Parallel()
newClient := func(t *testing.T) (*codersdk.ExperimentalClient, jwtutils.StaticKey) {
t.Helper()
key := jwtutils.StaticKey{ID: "1", Key: []byte(strings.Repeat("k", 64))}
client := newChatClient(t, func(options *coderdtest.Options) {
options.ChatFileTokenKeyCache = key
})
return client, key
}
uploadPNG := func(t *testing.T, ctx context.Context, client *codersdk.ExperimentalClient, organizationID uuid.UUID, name string) (codersdk.UploadChatFileResponse, []byte) {
t.Helper()
data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
uploaded, err := client.UploadChatFile(ctx, organizationID, "image/png", name, bytes.NewReader(data))
require.NoError(t, err)
return uploaded, data
}
get := func(t *testing.T, ctx context.Context, rawURL string) *http.Response {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
require.NoError(t, err)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return res
}
t.Run("MintAndRedeem", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
uploaded, data := uploadPNG(t, ctx, client, firstUser.OrganizationID, "evidence.png")
download, err := client.ChatFileDownloadURL(ctx, uploaded.ID)
require.NoError(t, err)
require.Equal(t, int64(len(data)), download.SizeBytes)
require.Equal(t, fmt.Sprintf("%x", sha256.Sum256(data)), download.SHA256)
require.Equal(t, "evidence.png", download.Name)
require.Equal(t, "image/png", download.MimeType)
require.True(t, download.ExpiresAt.After(time.Now()))
// expires_at must match the JWT exp claim, which is stored at
// second precision; sub-second drift would overstate validity.
require.True(t, download.ExpiresAt.Equal(download.ExpiresAt.Truncate(time.Second)))
res := get(t, ctx, download.URL)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "image/png", res.Header.Get("Content-Type"))
require.Equal(t, "no-store", res.Header.Get("Cache-Control"))
got, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, data, got)
})
t.Run("ExpiredToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, key := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "expired.png")
token, err := jwtutils.Sign(ctx, key, coderd.ChatFileDownloadClaims{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
},
FileID: uploaded.ID,
UserID: firstUser.UserID,
})
require.NoError(t, err)
downloadURL := client.URL.JoinPath("api", "experimental", "chats", "files", uploaded.ID.String(), "download")
query := downloadURL.Query()
query.Set("token", token)
downloadURL.RawQuery = query.Encode()
res := get(t, ctx, downloadURL.String())
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("TamperedToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "tampered.png")
download, err := client.ChatFileDownloadURL(ctx, uploaded.ID)
require.NoError(t, err)
downloadURL, err := url.Parse(download.URL)
require.NoError(t, err)
token := downloadURL.Query().Get("token")
require.NotEmpty(t, token)
first := byte('A')
if token[0] == first {
first = 'B'
}
query := downloadURL.Query()
query.Set("token", string(first)+token[1:])
downloadURL.RawQuery = query.Encode()
res := get(t, ctx, downloadURL.String())
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("TokenFileMismatch", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
fileA, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "a.png")
fileB, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "b.png")
download, err := client.ChatFileDownloadURL(ctx, fileA.ID)
require.NoError(t, err)
downloadURL, err := url.Parse(download.URL)
require.NoError(t, err)
downloadURL.Path = fmt.Sprintf("/api/experimental/chats/files/%s/download", fileB.ID)
res := get(t, ctx, downloadURL.String())
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("PlainGetRequiresAuthentication", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
anonymous := codersdk.NewExperimentalClient(codersdk.New(client.URL))
_, _, err := anonymous.GetChatFile(ctx, uuid.New())
requireSDKError(t, err, http.StatusUnauthorized)
})
t.Run("NonOwnerCannotMint", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "owner.png")
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err := otherClient.ChatFileDownloadURL(ctx, uploaded.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("RevokedShareCannotRedeem", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "shared.png")
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "shared evidence"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploaded.ID},
},
})
require.NoError(t, err)
memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberRaw)
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleRead},
})
require.NoError(t, err)
// The member can mint while the share is active.
download, err := memberClient.ChatFileDownloadURL(ctx, uploaded.ID)
require.NoError(t, err)
// Revoking the share invalidates the already-minted URL because
// redemption rechecks the minting user's access live.
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleDeleted},
})
require.NoError(t, err)
res := get(t, ctx, download.URL)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("SuspendedUserCannotRedeem", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _ := newClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
memberClient := codersdk.NewExperimentalClient(memberRaw)
uploaded, _ := uploadPNG(t, ctx, memberClient, firstUser.OrganizationID, "suspended.png")
download, err := memberClient.ChatFileDownloadURL(ctx, uploaded.ID)
require.NoError(t, err)
// Suspending the minting user invalidates the URL because
// redemption requires the token's user to still be active.
_, err = client.Client.UpdateUserStatus(ctx, member.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err)
res := get(t, ctx, download.URL)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
}
// seedChatGatewayRequest records one finished Coder Agents gateway request
// under sessionChatID, mirroring aibridged: the session ID is the spawning
// chat, and each usage is one provider response within that one request.
+5 -2
View File
@@ -42,8 +42,11 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han
}
// Start span with default span name. Span name will be updated to
// "method route" format once request finishes.
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.RequestURI))
// "method route" format once request finishes. The initial name
// excludes the query string because span names are exported to
// tracing backends at span start and some endpoints accept
// bearer credentials as query parameters.
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
defer span.End()
sw, ok := rw.(*StatusWriter)
+64
View File
@@ -5,11 +5,14 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
@@ -43,6 +46,24 @@ func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOp
return ctx, tracing.NoopSpan
}
// startNameRecorder captures span names as they are at span start, before
// EndHTTPSpan renames them, because span names are exported to tracing
// backends at span start.
type startNameRecorder struct {
mu sync.Mutex
names []string
}
func (r *startNameRecorder) OnStart(_ context.Context, s sdktrace.ReadWriteSpan) {
r.mu.Lock()
defer r.mu.Unlock()
r.names = append(r.names, s.Name())
}
func (*startNameRecorder) OnEnd(sdktrace.ReadOnlySpan) {}
func (*startNameRecorder) Shutdown(context.Context) error { return nil }
func (*startNameRecorder) ForceFlush(context.Context) error { return nil }
func Test_Middleware(t *testing.T) {
t.Parallel()
@@ -99,4 +120,47 @@ func Test_Middleware(t *testing.T) {
})
}
})
t.Run("QueryCredentialsNotExported", func(t *testing.T) {
t.Parallel()
// Some endpoints accept bearer credentials as query parameters
// (e.g. signed chat file download tokens). No exported span name
// or attribute may carry the query string.
const token = "super-secret-download-token"
startNames := &startNameRecorder{}
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(
sdktrace.WithSpanProcessor(startNames),
sdktrace.WithSpanProcessor(recorder),
)
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
r := httptest.NewRequest("GET", "/api/experimental/chats/files/abc/download?token="+token, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
r = r.WithContext(ctx)
tracing.Middleware(provider)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})).ServeHTTP(rw, r)
require.NoError(t, provider.ForceFlush(ctx))
require.NotEmpty(t, startNames.names)
for _, name := range startNames.names {
require.NotContains(t, name, token)
}
spans := recorder.Ended()
require.NotEmpty(t, spans)
for _, span := range spans {
require.NotContains(t, span.Name(), token)
for _, attr := range span.Attributes() {
require.NotContains(t, attr.Value.Emit(), token,
"span attribute %s must not carry query credentials", attr.Key)
}
}
})
}