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 -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: