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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user