Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function EditWebhookTriggerContent({
if (!triggerData) return undefined;
return {
triggerId: triggerData.triggerId,
executionUserId: triggerData.executionUserId,
activationMode: triggerData.activationMode === 'scheduled' ? 'scheduled' : 'webhook',
cronExpression: triggerData.cronExpression,
cronTimezone: triggerData.cronTimezone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri
async (formData: TriggerFormData) => {
await createTrigger({
triggerId: formData.triggerId,
runAsCreator: formData.runAsCreator,
activationMode: formData.activationMode,
cronExpression: formData.cronExpression,
cronTimezone: formData.cronTimezone,
Expand Down
74 changes: 73 additions & 1 deletion apps/web/src/components/webhook-triggers/TriggerForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { createRoot, type Root } from 'react-dom/client';
import type { TriggerForm as TriggerFormComponent, TriggerFormProps } from './TriggerForm';

jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));
jest.mock('next-auth/react', () => ({
useSession: () => ({
data: {
user: { id: 'oauth/github/creator', name: 'test-creator', email: 'creator@example.com' },
},
}),
}));
jest.mock('lucide-react', () => new Proxy({}, { get: () => () => null }));
jest.mock('@/components/ui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => createElement('div', {}, children),
Expand All @@ -30,7 +37,20 @@ jest.mock('@/components/ui/label', () => ({
createElement('label', props, children),
}));
jest.mock('@/components/ui/switch', () => ({ Switch: () => null }));
jest.mock('@/components/ui/checkbox', () => ({ Checkbox: () => null }));
jest.mock('@/components/ui/checkbox', () => ({
Checkbox: ({
onCheckedChange,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
onCheckedChange?: (checked: boolean) => void;
}) =>
createElement('input', {
...props,
type: 'checkbox',
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onCheckedChange?.(event.target.checked),
}),
}));
type SelectInjectedProps = {
onValueChange?: (value: string) => void;
selectDisabled?: boolean;
Expand Down Expand Up @@ -329,6 +349,58 @@ describe('TriggerForm variants', () => {
return { container: dom.container, onSubmit, allProps };
}

it('does not offer creator mode for personal triggers', () => {
const { container } = render({ mode: 'create' });
expect(container.textContent).not.toContain('Organization bot');
expect(container.textContent).not.toContain('test-creator');
});

it('requires authentication when selecting creator mode for an organization webhook', () => {
const { container, onSubmit } = render({ mode: 'create', organizationId: 'org-1' });
expect(
container.querySelector('[data-container-allocation="organization-bot"]')
).not.toBeNull();
click(container, 'test-creator');
expect(container.querySelector('#webhookAuthHeader')).not.toBeNull();
submit(container);
expect(onSubmit).not.toHaveBeenCalled();
const header = container.querySelector<HTMLInputElement>('#webhookAuthHeader');
const secret = container.querySelector<HTMLInputElement>('#webhookAuthSecret');
if (!header || !secret) throw new Error('authentication inputs missing');
changeValue(header, 'x-secret');
changeValue(secret, 'short-secret');
submit(container);
expect(onSubmit).not.toHaveBeenCalled();
expect(container.textContent).toContain('at least 16 characters');
changeValue(secret, 'shared-secret-0123456789');
submit(container);
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
runAsCreator: true,
webhookAuth: { enabled: true, header: 'x-secret', secret: 'shared-secret-0123456789' },
})
);
});

it('shows saved creator mode as immutable when editing', () => {
const { container, onSubmit } = render({
organizationId: 'org-1',
initialData: {
...initialData('high', 'scheduled'),
executionUserId: 'oauth/github/creator',
},
});
expect(
container
.querySelector('[data-container-allocation="creator"]')
?.getAttribute('data-disabled')
).toBe('true');
expect(container.textContent).toContain('test-creator');
click(container, 'Organization bot');
submit(container);
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ runAsCreator: true }));
});

it.each(['webhook', 'scheduled'] as const)(
'omits unset legacy %s edit variants, including after catalogue metadata arrives',
async activationMode => {
Expand Down
77 changes: 72 additions & 5 deletions apps/web/src/components/webhook-triggers/TriggerForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
Expand Down Expand Up @@ -35,6 +36,8 @@ import {
triggerIdSchema,
triggerIdCreateSchema,
RESERVED_TRIGGER_IDS,
MIN_CREATOR_WEBHOOK_SECRET_LENGTH,
CREATOR_WEBHOOK_SECRET_LENGTH_MESSAGE,
} from '@/lib/webhook-trigger-validation';
import { TimezoneSelector } from './TimezoneSelector';
import { ScheduleBuilder } from './ScheduleBuilder';
Expand All @@ -45,6 +48,7 @@ import type { AgentMode } from '@/components/cloud-agent/types';

export type TriggerFormData = {
triggerId: string;
runAsCreator?: boolean;
activationMode: 'webhook' | 'scheduled';
cronExpression?: string;
cronTimezone?: string;
Expand All @@ -70,6 +74,7 @@ export type TriggerFormProps = {
organizationId?: string;
initialData?: {
triggerId: string;
executionUserId?: string | null;
activationMode?: 'webhook' | 'scheduled';
cronExpression?: string | null;
cronTimezone?: string | null;
Expand Down Expand Up @@ -132,7 +137,13 @@ export function TriggerForm({
isLoadingCapabilities = false,
inboundUrl,
}: TriggerFormProps) {
const { data: session } = useSession();
const isEditMode = formMode === 'edit';
const [runAsCreator, setRunAsCreator] = useState(!!initialData?.executionUserId);
const creatorLabel =
isEditMode && initialData?.executionUserId !== session?.user?.id
? 'Webhook creator'
: session?.user?.name || session?.user?.email || 'Your account';

// Form state
const [activationMode, setActivationMode] = useState<'webhook' | 'scheduled'>(
Expand Down Expand Up @@ -192,6 +203,7 @@ export function TriggerForm({
// Reset form when initialData changes (for edit mode)
useEffect(() => {
if (initialData) {
setRunAsCreator(!!initialData.executionUserId);
setActivationMode(initialData.activationMode ?? 'webhook');
setCronExpression(initialData.cronExpression ?? '');
setCronTimezone(initialData.cronTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
Expand All @@ -210,6 +222,7 @@ export function TriggerForm({
setWebhookAuthHeader(initialData.webhookAuthHeader ?? '');
}
if (!initialData) {
setRunAsCreator(false);
setWebhookAuthEnabled(false);
setWebhookAuthHeader('');
setSandboxAllocation('automatic');
Expand Down Expand Up @@ -240,10 +253,15 @@ export function TriggerForm({
const trimmedWebhookAuthSecret = webhookAuthSecret.trim();
const webhookAuthHeaderError =
webhookAuthEnabled && !trimmedWebhookAuthHeader ? 'Webhook auth header is required' : null;
const webhookAuthSecretError =
webhookAuthEnabled && !initialWebhookAuthConfigured && !trimmedWebhookAuthSecret
const webhookAuthSecretError = !webhookAuthEnabled
? null
: !initialWebhookAuthConfigured && !trimmedWebhookAuthSecret
? 'Webhook auth secret is required when enabling authentication'
: null;
: runAsCreator &&
trimmedWebhookAuthSecret &&
trimmedWebhookAuthSecret.length < MIN_CREATOR_WEBHOOK_SECRET_LENGTH
? CREATOR_WEBHOOK_SECRET_LENGTH_MESSAGE
: null;

// Trigger ID validation — use stricter schema for new triggers
const activeTriggerIdSchema = isEditMode ? triggerIdSchema : triggerIdCreateSchema;
Expand Down Expand Up @@ -324,6 +342,10 @@ export function TriggerForm({
errors.push(webhookAuthHeaderError);
}

if (runAsCreator && !isScheduled && !webhookAuthEnabled) {
errors.push('Webhook authentication is required when running as the creator');
}

if (!isScheduled && webhookAuthSecretError) {
errors.push(webhookAuthSecretError);
}
Expand All @@ -350,6 +372,8 @@ export function TriggerForm({
profileId,
webhookAuthHeaderError,
webhookAuthSecretError,
runAsCreator,
webhookAuthEnabled,
sandboxAllocation,
initialSandboxAllocation,
isEditMode,
Expand Down Expand Up @@ -390,6 +414,7 @@ export function TriggerForm({

return {
triggerId,
runAsCreator: organizationId ? runAsCreator : undefined,
activationMode,
cronExpression: isScheduled ? cronExpression.trim() : undefined,
cronTimezone: isScheduled ? cronTimezone : undefined,
Expand All @@ -411,6 +436,8 @@ export function TriggerForm({
isFormValid,
profileId,
triggerId,
runAsCreator,
organizationId,
activationMode,
cronExpression,
cronTimezone,
Expand Down Expand Up @@ -647,7 +674,47 @@ export function TriggerForm({
)}
</div>

{/* Prompt Template */}
{organizationId && (
<div className="space-y-2">
<Label htmlFor="runAsCreator">Run as</Label>
<Select
value={runAsCreator ? 'creator' : 'organization-bot'}
onValueChange={value => {
setRunAsCreator(value === 'creator');
if (value === 'creator') setWebhookAuthEnabled(true);
}}
disabled={isEditMode || isLoading}
>
<SelectTrigger
id="runAsCreator"
className="min-h-11 w-full sm:w-80"
aria-describedby="runAsCreatorHelp"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="organization-bot">Organization bot</SelectItem>
<SelectItem value="creator">{creatorLabel}</SelectItem>
</SelectContent>
</Select>
<p id="runAsCreatorHelp" className="text-muted-foreground max-w-prose text-xs">
{runAsCreator
? 'Runs as the user who created this trigger, using organization credits. Only the creator can edit it. Execution stops if they leave the organization or their account is blocked.'
: 'Runs as the organization webhook bot by default.'}
{isEditMode
? ' Execution identity cannot be changed. Create a new trigger to use a different identity.'
: ' This choice cannot be changed after creation. Creator-run webhooks require authentication.'}
</p>
{runAsCreator && (
<p className="text-muted-foreground max-w-prose text-xs">
Organization profiles and repository settings remain shared. Members who can
edit them can affect runs under your identity. Enable this only if you trust
those members.
</p>
)}
</div>
)}

<PromptTemplateField
value={promptTemplate}
onChange={setPromptTemplate}
Expand Down Expand Up @@ -748,7 +815,7 @@ export function TriggerForm({
setWebhookAuthSecret('');
}
}}
disabled={isLoading}
disabled={isLoading || runAsCreator}
/>
<Label htmlFor="webhookAuthEnabled" className="cursor-pointer font-normal">
{webhookAuthEnabled ? 'Authentication enabled' : 'Authentication disabled'}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/webhook-agent/webhook-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type TriggerConfigResponse = {
namespace: string;
userId: string | null;
orgId: string | null;
executionUserId?: string | null;
createdAt: string;
isActive: boolean;
targetType: 'cloud_agent' | 'kiloclaw_chat';
Expand Down Expand Up @@ -38,6 +39,7 @@ export type TriggerConfigResponse = {
* Profile is referenced by ID - resolved at runtime in the worker.
*/
export type CreateTriggerInput = {
executionUserId?: string;
targetType?: 'cloud_agent' | 'kiloclaw_chat';
kiloclawInstanceId?: string;
githubRepo?: string;
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/webhook-trigger-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export const triggerIdCreateSchema = triggerIdSchema.refine(
'Trigger ID must be at least 8 characters'
);

// Creator-run webhooks act as a person and the inbound URL is not secret, so the
// shared secret is the only barrier against impersonation.
export const MIN_CREATOR_WEBHOOK_SECRET_LENGTH = 16;
export const CREATOR_WEBHOOK_SECRET_LENGTH_MESSAGE = `Webhook auth secret must be at least ${MIN_CREATOR_WEBHOOK_SECRET_LENGTH} characters when running as the creator`;

/**
* Transform user input to a valid trigger ID format.
* - Converts to lowercase
Expand Down
Loading