mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Generated
+2
-1
@@ -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';
|
||||
Generated
+4
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user