mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Adds the experimental API for chat project instructions: `GET`, `PUT`,
and `DELETE
/api/experimental/organizations/{organization}/chats/projects/{project}/instructions`,
behind the `chat-projects` experiment.
- `GET` returns 200 with empty instructions and null
`updated_by`/`updated_at` when nothing is set, so clients don't need a
404 path.
- `PUT` requires update access only (the read-back runs under a system
context, so a custom role with update but not read can save). It
sanitizes the text with `SanitizePromptText`. It rejects blank text with
400, pointing the caller to `DELETE`, and rejects text over the chat
prompt byte limit. It records the caller as `updated_by`. The upsert and
the read-back that returns the editor's profile run in one transaction.
- `DELETE` returns 204, including when nothing is set.
- Users without access to the project get 404, like the other project
routes.
- `PUT` and `DELETE` are audited as a new `chat_project_instructions`
resource (`write` and `delete`), with the project as the resource ID and
its name as the target. The diff tracks only the instruction text.
Saving unchanged text and deleting unset instructions are not audited. A
per-project advisory lock serializes reading the previous value with the
write, so concurrent edits produce accurate diffs. Migration `000615`
adds the enum value.
The codersdk client gains `ChatProjectInstructions`,
`UpdateChatProjectInstructions`, and `DeleteChatProjectInstructions`.
**What to scrutinize:** writes are last-writer-wins with no version
precondition, matching the deployment, user, and organization prompt
settings.
**Generated:** `coderd/apidoc/docs.go`, `coderd/apidoc/swagger.json`,
and `docs/reference/api/schemas.md` come from the Swagger annotations;
`site/src/api/typesGenerated.ts` comes from `codersdk`;
`docs/admin/security/audit-logs.md`, `coderd/database/dump.sql`, and
`coderd/database/models.go` come from the audit table and migration.
Together they are about 400 of this PR's lines.
**Validation:** `make gen` leaves no diff. `go test ./coderd -run
TestChatProject` and `go test ./enterprise/coderd -run
TestChatProjectInstructions` pass. `TestChatProjectInstructionsAudit`
checks the audit entries for create, update, and delete, and that no-op
saves and deletes are not audited. It covers unset GET, create, update,
blank and oversized PUT, delete, access denial, and cascade on project
delete.
---
Stack (merge top to bottom). #30377 through #29583 are f0ssel's split of
the original #29583, and this stack sits on #29583:
- #30377 data layer and project dialog (f0ssel)
- #30378 new-chat form locked to a project (f0ssel)
- #30379 project page route (f0ssel)
- #29583 sidebar folders (f0ssel), the base of this stack
- #30291 composer frame
- #30292 project chat list caches
- #30293 project page header
- #30294 project chats list
- #30367 instructions storage and prompt injection
- #30368 instructions API 👈
- #30369 instructions editor dialog
- #30370 instructions Details panel
---------
Co-authored-by: Garrett Delfosse <delfossegarrett@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
254 lines
9.6 KiB
Go
254 lines
9.6 KiB
Go
package coderd
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// @Summary Get chat project instructions
|
|
// @ID get-chat-project-instructions
|
|
// @Security CoderSessionToken
|
|
// @Tags Chats
|
|
// @Produce json
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Param project path string true "Chat project ID" format(uuid)
|
|
// @Success 200 {object} codersdk.ChatProjectInstructions
|
|
// @Router /api/experimental/organizations/{organization}/chats/projects/{project}/instructions [get]
|
|
// @x-apidocgen {"skip": true}
|
|
//
|
|
//nolint:revive // HTTP handler writes to ResponseWriter.
|
|
func (api *API) getChatProjectInstructions(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
project := httpmw.ChatProjectParam(r)
|
|
if !api.Authorize(r, policy.ActionRead, project.RBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
row, err := api.Database.GetChatProjectInstructionsByProjectID(ctx, project.ID)
|
|
if httpapi.Is404Error(err) {
|
|
// A project without a row has no instructions. Report the unset
|
|
// state rather than a 404 so clients can treat this as a singleton.
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatProjectInstructions{ProjectID: project.ID})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get chat project instructions.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatProjectInstructions(row))
|
|
}
|
|
|
|
// @Summary Update chat project instructions
|
|
// @ID update-chat-project-instructions
|
|
// @Security CoderSessionToken
|
|
// @Tags Chats
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Param project path string true "Chat project ID" format(uuid)
|
|
// @Param request body codersdk.UpdateChatProjectInstructionsRequest true "Update chat project instructions request"
|
|
// @Success 200 {object} codersdk.ChatProjectInstructions
|
|
// @Router /api/experimental/organizations/{organization}/chats/projects/{project}/instructions [put]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) putChatProjectInstructions(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
project := httpmw.ChatProjectParam(r)
|
|
apiKey := httpmw.APIKey(r)
|
|
if !api.Authorize(r, policy.ActionUpdate, project.RBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// Cap the raw request body to prevent excessive memory use from
|
|
// payloads padded with invisible characters that sanitize away.
|
|
var req codersdk.UpdateChatProjectInstructionsRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
|
|
return
|
|
}
|
|
instructions := codersdk.SanitizePromptText(req.Instructions)
|
|
if strings.TrimSpace(instructions) == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Instructions must not be blank.",
|
|
Detail: "Delete the instructions to clear them.",
|
|
Validations: []codersdk.ValidationError{{
|
|
Field: "instructions",
|
|
Detail: "Instructions must not be blank.",
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
// The same limit applies as for the deployment and user prompts.
|
|
if len(instructions) > api.chatLimits.MaxPromptBytes {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Instructions exceed maximum length.",
|
|
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", api.chatLimits.MaxPromptBytes, len(instructions)),
|
|
})
|
|
return
|
|
}
|
|
|
|
aReq, commitAudit := audit.InitRequestWithCancel[database.AuditableChatProjectInstructions](rw, &audit.RequestParams{
|
|
Audit: *api.Auditor.Load(),
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
OrganizationID: project.OrganizationID,
|
|
})
|
|
defer commitAudit(true)
|
|
aReq.Old = database.ChatProjectInstruction{ProjectID: project.ID, OrganizationID: project.OrganizationID}.Auditable(project.Name)
|
|
aReq.New = aReq.Old
|
|
|
|
// Write and read back in one transaction so a failed read rolls back
|
|
// the write instead of returning an error for a change that was saved.
|
|
// The audit diff is applied only after the transaction commits, so a
|
|
// rolled-back write is audited without one.
|
|
var (
|
|
row database.GetChatProjectInstructionsByProjectIDRow
|
|
previous, updated database.ChatProjectInstruction
|
|
)
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
previous = aReq.Old.ChatProjectInstruction
|
|
// The per-project lock serializes reading the previous value for
|
|
// the audit diff with the write.
|
|
if err := tx.AcquireLock(ctx, chatProjectInstructionsLockID(project.ID)); err != nil {
|
|
return xerrors.Errorf("acquire chat project instructions lock: %w", err)
|
|
}
|
|
// The ActionUpdate check above gates this endpoint, so the reads
|
|
// below run under a system context: a role that grants update
|
|
// without read can still save.
|
|
//nolint:gocritic // See above.
|
|
old, err := tx.GetChatProjectInstructionsByProjectID(dbauthz.AsSystemRestricted(ctx), project.ID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("get previous chat project instructions: %w", err)
|
|
}
|
|
if err == nil {
|
|
previous = old.ChatProjectInstruction
|
|
}
|
|
updated, err = tx.UpsertChatProjectInstructions(ctx, database.UpsertChatProjectInstructionsParams{
|
|
ProjectID: project.ID,
|
|
Instructions: instructions,
|
|
UpdatedBy: apiKey.UserID,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("upsert chat project instructions: %w", err)
|
|
}
|
|
// Read the row back to include the updating user's profile.
|
|
//nolint:gocritic // See above.
|
|
row, err = tx.GetChatProjectInstructionsByProjectID(dbauthz.AsSystemRestricted(ctx), project.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get chat project instructions: %w", err)
|
|
}
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to update chat project instructions.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// Saving unchanged instructions is not audited.
|
|
if updated.Instructions == previous.Instructions {
|
|
commitAudit(false)
|
|
}
|
|
aReq.Old = previous.Auditable(project.Name)
|
|
aReq.New = updated.Auditable(project.Name)
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatProjectInstructions(row))
|
|
}
|
|
|
|
// @Summary Delete chat project instructions
|
|
// @ID delete-chat-project-instructions
|
|
// @Security CoderSessionToken
|
|
// @Tags Chats
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Param project path string true "Chat project ID" format(uuid)
|
|
// @Success 204
|
|
// @Router /api/experimental/organizations/{organization}/chats/projects/{project}/instructions [delete]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) deleteChatProjectInstructions(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
project := httpmw.ChatProjectParam(r)
|
|
if !api.Authorize(r, policy.ActionUpdate, project.RBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
aReq, commitAudit := audit.InitRequestWithCancel[database.AuditableChatProjectInstructions](rw, &audit.RequestParams{
|
|
Audit: *api.Auditor.Load(),
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
OrganizationID: project.OrganizationID,
|
|
})
|
|
defer commitAudit(true)
|
|
aReq.Old = database.ChatProjectInstruction{ProjectID: project.ID, OrganizationID: project.OrganizationID}.Auditable(project.Name)
|
|
|
|
// Deleting unset instructions succeeds, so the request is idempotent.
|
|
// The audit diff is applied only after the transaction commits, so a
|
|
// rolled-back delete is audited without one.
|
|
var (
|
|
deleted bool
|
|
previous database.ChatProjectInstruction
|
|
)
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
deleted = false
|
|
// The per-project lock serializes reading the deleted value for the
|
|
// audit diff with the delete.
|
|
if err := tx.AcquireLock(ctx, chatProjectInstructionsLockID(project.ID)); err != nil {
|
|
return xerrors.Errorf("acquire chat project instructions lock: %w", err)
|
|
}
|
|
// The ActionUpdate check above gates this endpoint, so read the
|
|
// value being deleted under a system context: a role that grants
|
|
// update without read can still delete.
|
|
//nolint:gocritic // See above.
|
|
old, err := tx.GetChatProjectInstructionsByProjectID(dbauthz.AsSystemRestricted(ctx), project.ID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return xerrors.Errorf("get chat project instructions: %w", err)
|
|
}
|
|
previous = old.ChatProjectInstruction
|
|
if err := tx.DeleteChatProjectInstructionsByProjectID(ctx, project.ID); err != nil {
|
|
return xerrors.Errorf("delete chat project instructions: %w", err)
|
|
}
|
|
deleted = true
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to delete chat project instructions.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// Deleting unset instructions changes nothing, so it is not audited.
|
|
if !deleted {
|
|
commitAudit(false)
|
|
}
|
|
aReq.Old = previous.Auditable(project.Name)
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// chatProjectInstructionsLockID returns the advisory lock ID that serializes
|
|
// writes to one project's instructions.
|
|
func chatProjectInstructionsLockID(projectID uuid.UUID) int64 {
|
|
return database.GenLockID("chat-project-instructions:" + projectID.String())
|
|
}
|