mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: wire chat lifecycle hooks into chatd (#27429)
Wires chat lifecycle hooks into chatd, gated by the `agent-lifecycle-hooks` experiment. Part of the lifecycle hooks stack (#27401, #27428, #27430). See `docs/admin/setup/chat-lifecycle-hooks.md` for the consumer-facing contract. ## Summary When a hook URL is configured, chatd dispatches `session_start`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`, `post_compact`, and `stop` events to the consumer and applies its responses. ## Design - **Stateless**: Coder stores no hook dispatch or decision state. Delivery is at least once; consumers deduplicate on stable payload identifiers (chat ID, event type, tool-use ID) and answer duplicates with the same decision. - **Admission-time prompt effects**: `user_prompt_submit` dispatches exactly once per submission (create, send, queue, edit, subagent spawn) and folds its effects into the stored prompt as typed message parts: original-or-overridden user parts, then model-only `hook-context`, then a user-visible `hook-notice`. Hook context is stripped from every client-facing conversion; hook notices are excluded from model prompts. The server rejects hook parts in client-submitted content. - **Tool gating**: `pre_tool_use` allow can override tool input; deny becomes a synthetic denied tool result, with any returned model context persisted as a model-only transcript row so it never reaches clients. The denial text identifies an external policy (the deployment's lifecycle hook) as the source and marks the decision as persistent, so the model explains the denial instead of retrying it or misreporting it as an infrastructure failure. - **Fail closed**: a dispatch failure rejects the triggering request or moves the chat to the error state in the same transaction as the affected step, so a runnable state is never published with unapproved content. - **Admission before persistence**: `pre_tool_use` is dispatched for the calls the model produced, before the assistant message is stored. See "Staged tool admission" below. - **Fresh dispatch per tool call**: every non-provider-executed tool call is decided by its own `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. Retries re-dispatch the same logical event. ## Structure All hook dispatch flows through one seam: entry points build a `chathooks.Chat` (chat identity) and a `chathooks.Message` (event details) and call `Trigger.Trigger`, the only component that talks to the dispatcher. The integration lives in the `coderd/x/chatd/chathooks` subpackage, split by responsibility: - `trigger.go`: the trigger seam; builds the wire envelope per event, normalizes deny into a typed error, and holds the package's single enabled-check. - `effects.go`: pure conversion of hook results into transcript rows and prompt parts. - `errors.go`: failure classification (dispatch error messages, denial mapping, tool-result dispatch-failure scanning). - `tooluse.go`: the tool-call gate (`pre_tool_use` preflight, `post_tool_use` payloads, applying admitted input to the step). Server-bound glue stays in `coderd/x/chatd/hook_server.go`: the chat-parking dispatch error handlers, the step-commit row insertion wrappers, and the dynamic post-tool-use state loader, which depends on chatd validation types. This PR adopts the `codersdk/x/agenthooks` and `coderd/x/agenthooks/dispatch` import paths introduced at the tip of #27401; intermediate commits still reference the pre-move paths and are not individually buildable. ## Staged tool admission `pre_tool_use` originally ran at tool execution time, which is after the assistant message carrying the tool call was already committed. An `input_override` therefore had to rewrite stored message content in place. @hugodutka pointed out that chatd treats message content as immutable, and that the rewrite was a shortcut rather than a requirement. It was also a correctness problem in its own right: the rewrite only updated the database, so the transcript could show one input while a different one had executed. The hook now runs before the step is persisted: ```text provider stream ends (tool calls complete, in memory) -> pre_tool_use dispatch per call -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows -> execute ``` The step is inserted once, carrying the input the tool runs with. `UpdateChatMessageContentByID` and `Tx.UpdateMessageContent` are deleted from #27428, so message content stays immutable. Two consequences, both intentional: - **Clients converge rather than wait.** Tool-call parts still stream live, so a rewritten call briefly shows the model's proposed input before the committed message replaces it. The chat store already clears stream state when an assistant message arrives, so the stored input wins with no frontend change and no added latency before tool cards appear. - **A call already in history was already admitted.** Execution consumes the stored input instead of dispatching a second decision, which keeps one dispatch and one set of hook effects per call. A consumer policy change between admission and execution applies to later calls, not to calls already admitted. The per-chat debug endpoint still records the provider's original tool input. Its purpose is to report provider behavior, and it requires an explicit per-chat debug flag; the invariant here covers the transcript. ## Configuration Adds `chat-hook-url`, `chat-hook-secret`, `chat-hook-timeout`, and `chat-hook-enabled` deployment options with startup validation. The flags are hidden from `coder server --help` while the feature is experimental; the setup guide documents them. ## Tool input validation Built-in tool arguments reach a consumer as raw JSON with key spelling preserved, but the tools decode those bytes with Go, which matches struct fields case-insensitively and keeps the last match. A policy reading `path` could therefore authorize one value while the tool executed another, and a lone case variant such as `{"PATH":"/secret"}` was invisible to a policy checking for `path`. Coder now rejects a built-in tool call whose input repeats a key or spells a schema property with different capitalization, before the `pre_tool_use` dispatch, so a consumer is never asked to authorize bytes whose meaning depends on the reader. Rejected calls produce an error result the model can retry; unambiguous calls in the same batch still run. A consumer-authored `input_override` is rechecked after the dispatch and fails the turn closed, because the model cannot correct it. Dynamic and MCP inputs are excluded because the client and the workspace agent execute those calls rather than coderd. Two paths needed more than a schema check. Execution resolves a deprecated tool name to its canonical tool, so validation resolves aliases first. The `edit_files` decoder also reads `search` and `replace`, which its schema does not advertise, so those aliases are now matched exactly and their case variants ignored. A hook denial now returns a structured 403 carrying `kind: "hook_denied"`, mirroring the dispatch-failure response that already carries its own kind. Without it a client cannot tell a policy decision apart from a generic failure, and the chat UI titled a denial "Request failed". Adding a kind needs no migration: `ChatErrorKind` is persisted only inside the JSONB `chats.last_error` column, whose decoder accepts unknown kinds. The hook docs also correct the tool-input convergence window. A batch dispatches sequentially before the assistant row commits, so the original input stays visible for a span that scales with the number of tool calls in the step rather than a single hook timeout. > This PR was written by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
+47
-6
@@ -290,6 +290,15 @@ const (
|
||||
ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference"
|
||||
ChatMessagePartTypeContextFile ChatMessagePartType = "context-file"
|
||||
ChatMessagePartTypeSkill ChatMessagePartType = "skill"
|
||||
// ChatMessagePartTypeHookContext is model context injected into a user
|
||||
// prompt by a lifecycle hook. It is included in model prompt assembly
|
||||
// and stripped from every client-facing conversion; the server rejects
|
||||
// it in client-submitted content.
|
||||
ChatMessagePartTypeHookContext ChatMessagePartType = "hook-context"
|
||||
// ChatMessagePartTypeHookNotice is a user-facing notice attached to a
|
||||
// user prompt by a lifecycle hook. It is excluded from model prompt
|
||||
// assembly; the server rejects it in client-submitted content.
|
||||
ChatMessagePartTypeHookNotice ChatMessagePartType = "hook-notice"
|
||||
)
|
||||
|
||||
// AllChatMessagePartTypes returns all known ChatMessagePartType values.
|
||||
@@ -304,6 +313,8 @@ func AllChatMessagePartTypes() []ChatMessagePartType {
|
||||
ChatMessagePartTypeFileReference,
|
||||
ChatMessagePartTypeContextFile,
|
||||
ChatMessagePartTypeSkill,
|
||||
ChatMessagePartTypeHookContext,
|
||||
ChatMessagePartTypeHookNotice,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +343,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType {
|
||||
// and wastes space in persisted chat_messages rows.
|
||||
type ChatMessagePart struct {
|
||||
Type ChatMessagePartType `json:"type"`
|
||||
Text string `json:"text" variants:"text,reasoning"`
|
||||
Text string `json:"text" variants:"text,reasoning,hook-notice"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty" variants:"tool-call?,tool-result?"`
|
||||
ToolName string `json:"tool_name,omitempty" variants:"tool-call?,tool-result?"`
|
||||
MCPServerConfigID uuid.NullUUID `json:"mcp_server_config_id,omitempty" format:"uuid" variants:"tool-call?,tool-result?"`
|
||||
@@ -633,18 +644,28 @@ type EditChatMessageRequest struct {
|
||||
|
||||
// CreateChatMessageResponse is the response from adding a message to a chat.
|
||||
type CreateChatMessageResponse struct {
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
// Messages contains all user-visible messages inserted by the send, in
|
||||
// insertion order. A queued send on an errored chat may promote the
|
||||
// previous queue head, so clients must upsert the full batch.
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
QueuedMessage *ChatQueuedMessage `json:"queued_message,omitempty"`
|
||||
Queued bool `json:"queued"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// EditChatMessageResponse is the response from editing a message in a chat.
|
||||
// Edits are always synchronous (no queueing), so the message is returned
|
||||
// directly.
|
||||
type EditChatMessageResponse struct {
|
||||
Message ChatMessage `json:"message"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Message ChatMessage `json:"message"`
|
||||
// Messages holds every user-visible message inserted by the edit, in
|
||||
// insertion order. Hook-generated suffix messages may follow Message,
|
||||
// so clients must upsert the full batch.
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
// DeletedMessageIDs holds the IDs of previously visible messages the
|
||||
// edit removed, including stale hook notices from the edited turn.
|
||||
// Clients should drop them from local caches.
|
||||
DeletedMessageIDs []int64 `json:"deleted_message_ids,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// UploadChatFileResponse is the response from uploading a chat file.
|
||||
@@ -1720,6 +1741,8 @@ const (
|
||||
ChatErrorKindMissingKey ChatErrorKind = "missing_key"
|
||||
ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled"
|
||||
ChatErrorKindContentFilter ChatErrorKind = "content_filter"
|
||||
ChatErrorKindHookDispatchFailed ChatErrorKind = "hook_dispatch_failed"
|
||||
ChatErrorKindHookDenied ChatErrorKind = "hook_denied"
|
||||
)
|
||||
|
||||
// AllChatErrorKinds contains every ChatErrorKind value.
|
||||
@@ -1736,6 +1759,8 @@ var AllChatErrorKinds = []ChatErrorKind{
|
||||
ChatErrorKindMissingKey,
|
||||
ChatErrorKindProviderDisabled,
|
||||
ChatErrorKindContentFilter,
|
||||
ChatErrorKindHookDispatchFailed,
|
||||
ChatErrorKindHookDenied,
|
||||
}
|
||||
|
||||
// ChatError represents a terminal chat error in persisted chat state or the
|
||||
@@ -2011,6 +2036,22 @@ type ChatUsageLimitExceededResponse struct {
|
||||
ResetsAt time.Time `json:"resets_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// ChatHookDispatchFailedResponse is the error body returned when a
|
||||
// lifecycle hook dispatch fails during a synchronous chat operation.
|
||||
// Kind lets clients classify the failure without parsing message text.
|
||||
type ChatHookDispatchFailedResponse struct {
|
||||
Response
|
||||
Kind ChatErrorKind `json:"kind"`
|
||||
}
|
||||
|
||||
// ChatHookDeniedResponse is the error body returned when a lifecycle hook
|
||||
// denies a synchronous chat operation. Kind lets clients classify the denial
|
||||
// without parsing message text.
|
||||
type ChatHookDeniedResponse struct {
|
||||
Response
|
||||
Kind ChatErrorKind `json:"kind"`
|
||||
}
|
||||
|
||||
type chatUsageLimitExceededError struct {
|
||||
err *Error
|
||||
response ChatUsageLimitExceededResponse
|
||||
|
||||
+13
-1
@@ -314,6 +314,12 @@ func TestChatMessagePartVariantTags(t *testing.T) {
|
||||
"skill_dir": "internal only, used by read_skill tools (typescript:\"-\")",
|
||||
"context_file_skill_meta_file": "internal only, restored on subsequent turns (typescript:\"-\")",
|
||||
}
|
||||
// Part types intentionally excluded from all generated variants.
|
||||
// If you add a new part type, either reference it in a variants
|
||||
// tag or add it here with a reason.
|
||||
excludedTypes := map[codersdk.ChatMessagePartType]string{
|
||||
codersdk.ChatMessagePartTypeHookContext: "internal only, stripped from client-facing conversions by db2sdk",
|
||||
}
|
||||
knownTypes := make(map[codersdk.ChatMessagePartType]bool)
|
||||
for _, pt := range codersdk.AllChatMessagePartTypes() {
|
||||
knownTypes[pt] = true
|
||||
@@ -353,8 +359,14 @@ func TestChatMessagePartVariantTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every known type must appear in at least one variants tag.
|
||||
// Every known type must appear in at least one variants tag
|
||||
// unless it is intentionally excluded from client codegen.
|
||||
for pt := range knownTypes {
|
||||
if _, excluded := excludedTypes[pt]; excluded {
|
||||
assert.False(t, coveredTypes[pt],
|
||||
"ChatMessagePartType %q is in excludedTypes but referenced by a variants tag; %s", pt, editHint)
|
||||
continue
|
||||
}
|
||||
assert.True(t, coveredTypes[pt],
|
||||
"ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint)
|
||||
}
|
||||
|
||||
+87
-2
@@ -4332,6 +4332,51 @@ Write out the current server config as YAML to stdout.`,
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "debugLoggingEnabled",
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook URL",
|
||||
Description: "HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.",
|
||||
Flag: "chat-hook-url",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_URL",
|
||||
Value: &c.AI.Chat.HookURL,
|
||||
Default: "",
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "hookURL",
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook Secret",
|
||||
Description: "Shared secret used to sign chat agent lifecycle hook JWTs.",
|
||||
Flag: "chat-hook-secret",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_SECRET",
|
||||
Value: &c.AI.Chat.HookSecret,
|
||||
Default: "",
|
||||
Group: &deploymentGroupChat,
|
||||
Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"),
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook Timeout",
|
||||
Description: "Maximum time to wait for a chat agent lifecycle hook response.",
|
||||
Flag: "chat-hook-timeout",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_TIMEOUT",
|
||||
Value: &c.AI.Chat.HookTimeout,
|
||||
Default: (1500 * time.Millisecond).String(),
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "hookTimeout",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook Enabled",
|
||||
Description: "Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. Requires the agent-lifecycle-hooks experiment.",
|
||||
Flag: "chat-hook-enabled",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_ENABLED",
|
||||
Value: &c.AI.Chat.HookEnabled,
|
||||
Default: "true",
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "hookEnabled",
|
||||
},
|
||||
{
|
||||
Name: "Chat: AI Gateway Routing Enabled",
|
||||
Description: "Deprecated: AI Gateway routing is now the only routing path. Setting this value has no effect. This option will be removed in a future release.",
|
||||
@@ -5014,8 +5059,12 @@ type AIBridgeProxyConfig struct {
|
||||
}
|
||||
|
||||
type ChatConfig struct {
|
||||
AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"`
|
||||
DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"`
|
||||
AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"`
|
||||
DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"`
|
||||
HookURL serpent.URL `json:"hook_url" typescript:",notnull"`
|
||||
HookSecret serpent.String `json:"hook_secret" typescript:",notnull"`
|
||||
HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"`
|
||||
HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"`
|
||||
// Deprecated: AI Gateway routing is now the only routing path. Setting this
|
||||
// value has no effect. This option will be removed in a future release.
|
||||
AIGatewayRoutingEnabled serpent.Bool `json:"ai_gateway_routing_enabled" typescript:",notnull" swaggerignore:"true"`
|
||||
@@ -5065,6 +5114,38 @@ func (c *DeploymentValues) Validate() error {
|
||||
refresh, access,
|
||||
)
|
||||
}
|
||||
|
||||
// Disabled hooks must not validate inert settings.
|
||||
if c.AI.Chat.HookEnabled.Value() {
|
||||
if c.AI.Chat.HookURL.String() != "" {
|
||||
hookURL := c.AI.Chat.HookURL.Value()
|
||||
if hookURL.Scheme != "https" {
|
||||
return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL")
|
||||
}
|
||||
if hookURL.Host == "" {
|
||||
return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete HTTPS URL")
|
||||
}
|
||||
// The configured string is signed verbatim as the JWT audience,
|
||||
// and neither component is ever transmitted, so a consumer
|
||||
// configured with the URL it actually serves would never match.
|
||||
if hookURL.Fragment != "" || hookURL.RawFragment != "" || hookURL.User != nil {
|
||||
return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a plain HTTPS URL")
|
||||
}
|
||||
if c.AI.Chat.HookSecret.Value() == "" {
|
||||
return xerrors.New("chat hook secret is required when chat hook URL is set; set --chat-hook-secret")
|
||||
}
|
||||
// The hook SDK rejects HS256 secrets shorter than 32 bytes.
|
||||
if len(c.AI.Chat.HookSecret.Value()) < 32 {
|
||||
return xerrors.New("chat hook secret must be at least 32 bytes of cryptographically random data; set --chat-hook-secret to a longer value")
|
||||
}
|
||||
|
||||
hookTimeout := c.AI.Chat.HookTimeout.Value()
|
||||
if hookTimeout <= 0 || hookTimeout > 5*time.Second {
|
||||
return xerrors.Errorf("chat hook timeout (%s) must be greater than zero and no more than 5s; set --chat-hook-timeout to a valid duration", hookTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5287,6 +5368,7 @@ const (
|
||||
ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality.
|
||||
ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats.
|
||||
ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents.
|
||||
ExperimentAgentLifecycleHooks Experiment = "agent-lifecycle-hooks" // Enables chat lifecycle hook webhooks for agent chats.
|
||||
)
|
||||
|
||||
func (e Experiment) DisplayName() string {
|
||||
@@ -5319,6 +5401,8 @@ func (e Experiment) DisplayName() string {
|
||||
return "Chat Advisor"
|
||||
case ExperimentChatVirtualDesktop:
|
||||
return "Chat Virtual Desktop"
|
||||
case ExperimentAgentLifecycleHooks:
|
||||
return "Agent Lifecycle Hooks"
|
||||
default:
|
||||
// Split on hyphen and convert to title case
|
||||
// e.g. "mcp-server-http" -> "Mcp Server Http"
|
||||
@@ -5343,6 +5427,7 @@ var ExperimentsKnown = Experiments{
|
||||
ExperimentAIGatewayCostControl,
|
||||
ExperimentChatAdvisor,
|
||||
ExperimentChatVirtualDesktop,
|
||||
ExperimentAgentLifecycleHooks,
|
||||
}
|
||||
|
||||
// ExperimentsSafe should include all experiments that are safe for
|
||||
|
||||
@@ -82,6 +82,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
|
||||
"Email Auth: Password": {
|
||||
yaml: true,
|
||||
},
|
||||
"Chat: Hook Secret": {
|
||||
yaml: true,
|
||||
},
|
||||
"Notifications: Email Auth: Password": {
|
||||
yaml: true,
|
||||
},
|
||||
@@ -739,6 +742,7 @@ func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) {
|
||||
dv := &codersdk.DeploymentValues{}
|
||||
dv.Sessions.DefaultDuration = serpent.Duration(access)
|
||||
dv.Sessions.RefreshDefaultDuration = serpent.Duration(refresh)
|
||||
dv.AI.Chat.HookTimeout = serpent.Duration(1500 * time.Millisecond)
|
||||
return dv
|
||||
}
|
||||
|
||||
@@ -783,6 +787,125 @@ func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
disabled bool
|
||||
url string
|
||||
secret string
|
||||
timeout time.Duration
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "NoURL",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "DisabledSkipsValidation",
|
||||
disabled: true,
|
||||
url: "http://hooks.example.com/agent",
|
||||
timeout: 0,
|
||||
},
|
||||
{
|
||||
name: "Valid",
|
||||
url: "https://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "HTTPURL",
|
||||
url: "http://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "chat hook URL must use HTTPS",
|
||||
},
|
||||
{
|
||||
name: "HostlessURL",
|
||||
url: "https:///hook",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must include a host",
|
||||
},
|
||||
{
|
||||
name: "FragmentURL",
|
||||
url: "https://hooks.example.com/agent#frag",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must not contain a fragment or userinfo",
|
||||
},
|
||||
{
|
||||
name: "UserinfoURL",
|
||||
url: "https://user:pass@hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must not contain a fragment or userinfo",
|
||||
},
|
||||
{
|
||||
name: "MissingSecret",
|
||||
url: "https://hooks.example.com/agent",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "chat hook secret is required",
|
||||
},
|
||||
{
|
||||
name: "ShortSecret",
|
||||
url: "https://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcde",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "chat hook secret must be at least 32 bytes",
|
||||
},
|
||||
{
|
||||
name: "ZeroTimeout",
|
||||
url: "https://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 0,
|
||||
wantErr: "chat hook timeout",
|
||||
},
|
||||
{
|
||||
name: "NegativeTimeout",
|
||||
url: "https://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: -time.Millisecond,
|
||||
wantErr: "chat hook timeout",
|
||||
},
|
||||
{
|
||||
name: "TimeoutAboveMaximum",
|
||||
url: "https://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 5*time.Second + time.Millisecond,
|
||||
wantErr: "chat hook timeout",
|
||||
},
|
||||
{
|
||||
name: "NoURLSkipsTimeoutValidation",
|
||||
timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := &codersdk.DeploymentValues{}
|
||||
dv.Sessions.DefaultDuration = serpent.Duration(time.Hour)
|
||||
dv.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
|
||||
dv.AI.Chat.HookEnabled = serpent.Bool(!tt.disabled)
|
||||
dv.AI.Chat.HookSecret = serpent.String(tt.secret)
|
||||
dv.AI.Chat.HookTimeout = serpent.Duration(tt.timeout)
|
||||
if tt.url != "" {
|
||||
require.NoError(t, dv.AI.Chat.HookURL.Set(tt.url))
|
||||
}
|
||||
|
||||
err := dv.Validate()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user