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>
124 lines
5.8 KiB
Go
124 lines
5.8 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestChatProjectInstructions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
owner, err := client.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
project := createChatProject(t, client, firstUser.OrganizationID, "Instructions Project")
|
|
|
|
// A project without instructions reports the unset state.
|
|
instructions, err := client.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatProjectInstructions{ProjectID: project.ID}, instructions)
|
|
|
|
created, err := client.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
// Invisible characters are stripped before storage.
|
|
Instructions: "Always\u200b reply in French.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Always reply in French.", created.Instructions)
|
|
require.NotNil(t, created.UpdatedBy)
|
|
require.Equal(t, owner.ID, created.UpdatedBy.ID)
|
|
require.Equal(t, owner.Username, created.UpdatedBy.Username)
|
|
require.NotNil(t, created.UpdatedAt)
|
|
|
|
instructions, err = client.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, created, instructions)
|
|
|
|
// Another editor replaces the text and becomes the recorded editor.
|
|
adminRaw, admin := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID,
|
|
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
|
|
adminClient := codersdk.NewExperimentalClient(adminRaw)
|
|
updated, err := adminClient.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
Instructions: "Always reply in German.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Always reply in German.", updated.Instructions)
|
|
require.NotNil(t, updated.UpdatedBy)
|
|
require.Equal(t, admin.ID, updated.UpdatedBy.ID)
|
|
require.False(t, updated.UpdatedAt.Before(*created.UpdatedAt))
|
|
|
|
// Clearing instructions is a delete, not a blank update.
|
|
for _, blank := range []string{"", " \n\u200b "} {
|
|
_, err = client.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
Instructions: blank,
|
|
})
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Equal(t, "Instructions must not be blank.", sdkErr.Message)
|
|
}
|
|
|
|
_, err = client.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
Instructions: strings.Repeat("x", codersdk.DefaultChatMaxPromptBytes+1),
|
|
})
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Equal(t, "Instructions exceed maximum length.", sdkErr.Message)
|
|
|
|
// Rejected updates leave the stored instructions alone.
|
|
instructions, err = client.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, updated, instructions)
|
|
|
|
// Users are soft deleted, so a deleted editor must be dropped explicitly
|
|
// rather than reported by name.
|
|
require.NoError(t, client.DeleteUser(ctx, admin.ID))
|
|
instructions, err = client.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, instructions.UpdatedBy)
|
|
require.Equal(t, updated.Instructions, instructions.Instructions)
|
|
require.Equal(t, updated.UpdatedAt, instructions.UpdatedAt)
|
|
|
|
// A member who cannot read the project can neither see nor change its
|
|
// instructions, and the response does not reveal that the project exists.
|
|
memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
|
member := codersdk.NewExperimentalClient(memberRaw)
|
|
_, err = member.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.Equal(t, http.StatusNotFound, coderdtest.SDKError(t, err).StatusCode())
|
|
_, err = member.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
Instructions: "Hijacked.",
|
|
})
|
|
require.Equal(t, http.StatusNotFound, coderdtest.SDKError(t, err).StatusCode())
|
|
err = member.DeleteChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.Equal(t, http.StatusNotFound, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
require.NoError(t, client.DeleteChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID))
|
|
instructions, err = client.ChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatProjectInstructions{ProjectID: project.ID}, instructions)
|
|
// Deleting unset instructions succeeds.
|
|
require.NoError(t, client.DeleteChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID))
|
|
|
|
// Deleting a project with instructions succeeds and removes them.
|
|
_, err = client.UpdateChatProjectInstructions(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectInstructionsRequest{
|
|
Instructions: "Always reply in Spanish.",
|
|
})
|
|
require.NoError(t, err)
|
|
stored, err := db.GetChatProjectInstructionsByProjectID(dbauthz.AsSystemRestricted(ctx), project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, firstUser.OrganizationID, stored.ChatProjectInstruction.OrganizationID)
|
|
require.NoError(t, client.DeleteChatProject(ctx, firstUser.OrganizationID, project.ID))
|
|
_, err = db.GetChatProjectInstructionsByProjectID(dbauthz.AsSystemRestricted(ctx), project.ID)
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
}
|