feat(hooks/ui): script editor + builtin readonly + CSP hardening

Phase 06 UI: surface the script handler + builtin affordances that
backend Phases 02-05 already support.

- Deps: prismjs@^1.30.0 (CVE-2024-53382 patched) + react-simple-code-editor +
  @types/prismjs. pnpm override pins prismjs to ≥1.30.0 across transitives.
- CSP meta in index.html: default-src 'self', script-src 'self' (no
  'unsafe-eval' — Prism uses static highlight, never executes user JS),
  style-src allows Google Fonts + jsdelivr katex, connect-src allows ws:/wss:
  for WS, frame-ancestors 'none' + base-uri 'self'.
- Schema: HookHandlerTypeEnum adds "script"; script_source field with 32 KiB
  cap + required-when-script refinement.
- HookConfig TS type: handler_type adds "script", source adds "builtin".
- Components:
  * script-editor.tsx — Prism.highlight JS with readonly + inline compile-error
    banner. parseLineCol exported helper matches goja "Line N:M" format
    (verified against goja parser/error.go); locale-aware banner via
    t("form.compileError", {line, col}); fallback renders raw message.
  * system-badge.tsx — "System" chip rendered next to the scope on list rows
    where source==="builtin".
- hook-form-dialog.tsx: adds "script" radio; renders ScriptEditor when
  selected; readonly cascade for builtin rows (event/scope/matcher/if_expr +
  all handler sub-forms + timeout/on_timeout/priority all disabled; enabled
  switch stays actionable). Save button label flips to "Save toggle" on
  builtin. Top-of-form banner informs user.
- hook-list-row.tsx: SystemBadge + delete button disabled for builtin rows.
  New emerald color for script handler chip.
- hooks-page.tsx: buildConfig script branch → {source: data.script_source}.
- i18n: en/vi/zh get scriptSource, scriptSchemaHelp, compileError,
  builtinReadonly, saveToggle, systemBadge, reason, scriptSourceRequired.
- Tests: 5 parseLineCol cases (happy + multi-digit + null + garbage +
  multi-error first-match) — all green.
- Build: pnpm tsc --noEmit + lint + build all clean. Hooks page chunk
  65 KB / 19 KB gzip.
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 33d188f01f
commit 31eb953508
14 files changed
+292 -26

No files matched your search

+14
View File
@@ -4,6 +4,20 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!--
Content-Security-Policy (Phase 06):
- default-src 'self' — deny everything by default.
- script-src 'self' — no 'unsafe-eval'; Prism uses static highlighting only
so the script editor stays XSS-safe even when users paste malicious JS
source (it is rendered as text, never executed in-page).
- style-src 'self' 'unsafe-inline' — Tailwind/inline loader keyframes +
Google Fonts stylesheet require inline styles.
- font-src self + gstatic — Google Fonts woff2 files.
- img-src self + data: — base64 avatars/thumbnails.
- connect-src self + ws:/wss: — websocket to gateway. http(s): for CORS
calls to backend on a different origin in dev.
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' ws: wss: http: https:; frame-ancestors 'none'; base-uri 'self';" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
+9 -1
View File
@@ -37,6 +37,7 @@
"jszip": "^3.10.1",
"lucide-react": "^1.7.0",
"mermaid": "^11.14.0",
"prismjs": "^1.30.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -44,6 +45,7 @@
"react-i18next": "^17.0.1",
"react-markdown": "^10.1.0",
"react-router": "^7.13.2",
"react-simple-code-editor": "^0.14.1",
"recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
@@ -59,6 +61,7 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdast": "^4.0.4",
"@types/prismjs": "^1.26.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
@@ -77,5 +80,10 @@
"vitest": "^4.1.4",
"ws": "^8.20.0"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a",
"pnpm": {
"overrides": {
"prismjs": "^1.30.0"
}
}
}
+31
View File
@@ -77,6 +77,9 @@ importers:
mermaid:
specifier: ^11.14.0
version: 11.14.0
prismjs:
specifier: ^1.30.0
version: 1.30.0
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -98,6 +101,9 @@ importers:
react-router:
specifier: ^7.13.2
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-simple-code-editor:
specifier: ^0.14.1
version: 0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
@@ -138,6 +144,9 @@ importers:
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
'@types/prismjs':
specifier: ^1.26.6
version: 1.26.6
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -1553,6 +1562,9 @@ packages:
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -2798,6 +2810,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -2899,6 +2915,12 @@ packages:
react-dom:
optional: true
react-simple-code-editor@0.14.1:
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4808,6 +4830,8 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/prismjs@1.26.6': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -6354,6 +6378,8 @@ snapshots:
prelude-ls@1.2.1: {}
prismjs@1.30.0: {}
process-nextick-args@2.0.1: {}
property-information@7.1.0: {}
@@ -6499,6 +6525,11 @@ snapshots:
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
react-simple-code-editor@0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
get-nonce: 1.0.1
+2 -2
View File
@@ -10,7 +10,7 @@ export interface HookConfig {
tenant_id: string;
agent_id?: string | null;
event: string;
handler_type: "command" | "http" | "prompt";
handler_type: "command" | "http" | "prompt" | "script";
scope: "global" | "tenant" | "agent";
config: Record<string, unknown>;
matcher?: string;
@@ -20,7 +20,7 @@ export interface HookConfig {
priority: number;
enabled: boolean;
version: number;
source: "ui" | "api" | "seed";
source: "ui" | "api" | "seed" | "builtin";
metadata: Record<string, unknown>;
created_by?: string;
created_at: string;
+10 -2
View File
@@ -24,7 +24,8 @@
"scope": "Scope",
"matcher": "Matcher",
"enabled": "Enabled",
"actions": "Actions"
"actions": "Actions",
"systemBadge": "System"
},
"form": {
"title_create": "Create Hook",
@@ -47,6 +48,11 @@
"promptTemplate": "Prompt Template",
"model": "Model",
"maxInvocationsPerTurn": "Max Invocations per Turn",
"scriptSource": "Script source (ES5.1 JavaScript)",
"scriptSchemaHelp": "Export function handle(event) returning {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}. Limit 32 KiB.",
"compileError": "Syntax error at line {{line}}, column {{col}}",
"builtinReadonly": "Built-in hook — only the enabled toggle can be changed.",
"saveToggle": "Save toggle",
"save": "Save",
"cancel": "Cancel"
},
@@ -66,6 +72,7 @@
"firing": "Firing\u2026",
"decision": "Decision",
"duration": "Duration",
"reason": "Reason",
"stdout": "Stdout",
"stderr": "Stderr",
"statusCode": "Status code",
@@ -93,7 +100,8 @@
"validation": {
"promptRequiresMatcher": "Prompt hooks require a matcher or if_expr",
"invalidRegex": "Invalid regex",
"promptTemplateRequired": "Prompt template is required"
"promptTemplateRequired": "Prompt template is required",
"scriptSourceRequired": "Script source is required"
},
"decision": {
"allow": "Allow",
+10 -2
View File
@@ -24,7 +24,8 @@
"scope": "Phạm vi",
"matcher": "Bộ lọc",
"enabled": "Bật",
"actions": "Thao tác"
"actions": "Thao tác",
"systemBadge": "Hệ thống"
},
"form": {
"title_create": "Tạo Hook",
@@ -47,6 +48,11 @@
"promptTemplate": "Mẫu prompt",
"model": "Mô hình",
"maxInvocationsPerTurn": "Số lần gọi tối đa mỗi lượt",
"scriptSource": "Mã nguồn script (ES5.1 JavaScript)",
"scriptSchemaHelp": "Hàm handle(event) trả về {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}. Giới hạn 32 KiB.",
"compileError": "Lỗi cú pháp tại dòng {{line}}, cột {{col}}",
"builtinReadonly": "Hook dựng sẵn — chỉ có thể bật/tắt, không chỉnh sửa nội dung.",
"saveToggle": "Lưu bật/tắt",
"save": "Lưu",
"cancel": "Hủy"
},
@@ -66,6 +72,7 @@
"firing": "Đang kích hoạt\u2026",
"decision": "Quyết định",
"duration": "Thời gian",
"reason": "Lý do",
"stdout": "Stdout",
"stderr": "Stderr",
"statusCode": "Mã trạng thái",
@@ -93,7 +100,8 @@
"validation": {
"promptRequiresMatcher": "Hook prompt yêu cầu matcher hoặc if_expr",
"invalidRegex": "Regex không hợp lệ",
"promptTemplateRequired": "Mẫu prompt là bắt buộc"
"promptTemplateRequired": "Mẫu prompt là bắt buộc",
"scriptSourceRequired": "Mã nguồn script là bắt buộc"
},
"decision": {
"allow": "Cho phép",
+10 -2
View File
@@ -24,7 +24,8 @@
"scope": "范围",
"matcher": "匹配器",
"enabled": "已启用",
"actions": "操作"
"actions": "操作",
"systemBadge": "系统"
},
"form": {
"title_create": "创建钩子",
@@ -47,6 +48,11 @@
"promptTemplate": "Prompt 模板",
"model": "模型",
"maxInvocationsPerTurn": "每轮最大调用次数",
"scriptSource": "脚本源码(ES5.1 JavaScript)",
"scriptSchemaHelp": "函数 handle(event) 返回 {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}。限制 32 KiB。",
"compileError": "语法错误,位于第 {{line}} 行第 {{col}} 列",
"builtinReadonly": "内置钩子 — 仅可切换启用状态,不能修改内容。",
"saveToggle": "保存开关",
"save": "保存",
"cancel": "取消"
},
@@ -66,6 +72,7 @@
"firing": "触发中\u2026",
"decision": "决策",
"duration": "耗时",
"reason": "原因",
"stdout": "标准输出",
"stderr": "标准错误",
"statusCode": "状态码",
@@ -93,7 +100,8 @@
"validation": {
"promptRequiresMatcher": "Prompt 钩子需要 matcher 或 if_expr",
"invalidRegex": "无效的正则表达式",
"promptTemplateRequired": "Prompt 模板为必填项"
"promptTemplateRequired": "Prompt 模板为必填项",
"scriptSourceRequired": "脚本源码为必填项"
},
"decision": {
"allow": "允许",
@@ -15,6 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { hookFormSchema, type HookFormData } from "@/schemas/hooks.schema";
import type { HookConfig } from "@/hooks/use-hooks";
import { useAuthStore } from "@/stores/use-auth-store";
import { ScriptEditor } from "./script-editor";
const HOOK_EVENTS = [
"session_start", "user_prompt_submit", "pre_tool_use",
@@ -55,6 +56,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
});
const handlerType = watch("handler_type");
// Builtin rows (Phase 04/05) ship with source='builtin'. UI + backend agree:
// only `enabled` is mutable. All other inputs render as read-only, and the
// Save button label changes to reflect the narrowed scope.
const isBuiltin = initial?.source === "builtin";
useEffect(() => {
if (open) {
@@ -81,6 +86,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
prompt_template: (cfg.prompt_template as string) ?? "",
model: (cfg.model as string) ?? "",
max_invocations_per_turn: (cfg.max_invocations_per_turn as number) ?? 5,
script_source: (cfg.source as string) ?? "",
});
} else {
reset();
@@ -101,11 +107,17 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</DialogHeader>
<div className="flex-1 space-y-4 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
{isBuiltin && (
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300">
{t("form.builtinReadonly")}
</div>
)}
{/* Event */}
<div className="space-y-1.5">
<Label>{t("form.event")}</Label>
<Controller control={control} name="event" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
@@ -119,12 +131,18 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
{/* Handler type — `command` intentionally absent post-Wave-1. Lite users keep
existing rows but cannot create new ones via UI (DB/CLI path only). */}
existing rows but cannot create new ones via UI (DB/CLI path only).
`script` added in Phase 06 (goja sandbox; builtin PII redactor = Phase 05). */}
<div className="space-y-1.5">
<Label>{t("form.handlerType")}</Label>
<Controller control={control} name="handler_type" render={({ field }) => (
<RadioGroup value={field.value} onValueChange={field.onChange} className="flex gap-4">
{(["http", "prompt"] as const).map((ht) => (
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex gap-4"
disabled={isBuiltin}
>
{(["script", "http", "prompt"] as const).map((ht) => (
<div key={ht} className="flex items-center gap-1.5">
<RadioGroupItem value={ht} id={`ht-${ht}`} />
<Label htmlFor={`ht-${ht}`} className="cursor-pointer">
@@ -140,7 +158,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
<div className="space-y-1.5">
<Label>{t("form.scope")}</Label>
<Controller control={control} name="scope" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
@@ -156,7 +174,12 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
{/* Matcher */}
<div className="space-y-1.5">
<Label>{t("form.matcher")}</Label>
<Input {...register("matcher")} placeholder="^bash$" className="text-base md:text-sm font-mono" />
<Input
{...register("matcher")}
placeholder="^bash$"
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
{errors.matcher
? <p className="text-xs text-destructive">{errors.matcher.message}</p>
: <p className="text-xs text-muted-foreground">{t("form.matcherHint")}</p>
@@ -166,22 +189,49 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
{/* if_expr */}
<div className="space-y-1.5">
<Label>{t("form.ifExpr")}</Label>
<Input {...register("if_expr")} placeholder='tool_input.path.startsWith("/etc")' className="text-base md:text-sm font-mono" />
<Input
{...register("if_expr")}
placeholder='tool_input.path.startsWith("/etc")'
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
<p className="text-xs text-muted-foreground">{t("form.ifExprHint")}</p>
</div>
{/* Handler-specific sub-forms */}
{handlerType === "script" && (
<div className="space-y-3 rounded-lg border p-3">
<Controller
control={control}
name="script_source"
render={({ field }) => (
<ScriptEditor
value={field.value ?? ""}
onChange={field.onChange}
error={errors.script_source?.message as string | undefined}
readOnly={isBuiltin}
/>
)}
/>
</div>
)}
{handlerType === "http" && (
<div className="space-y-3 rounded-lg border p-3">
<div className="space-y-1.5">
<Label>{t("form.url")}</Label>
<Input {...register("url")} placeholder="https://hooks.example.com/agent" className="text-base md:text-sm" />
<Input
{...register("url")}
placeholder="https://hooks.example.com/agent"
className="text-base md:text-sm"
disabled={isBuiltin}
/>
{errors.url && <p className="text-xs text-destructive">{errors.url.message}</p>}
</div>
<div className="space-y-1.5">
<Label>{t("form.method")}</Label>
<Controller control={control} name="method" render={({ field }) => (
<Select value={field.value ?? "POST"} onValueChange={field.onChange}>
<Select value={field.value ?? "POST"} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
@@ -195,7 +245,13 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
<div className="space-y-1.5">
<Label>{t("form.bodyTemplate")}</Label>
<Textarea {...register("body_template")} rows={3} placeholder='{"event": "{{.Event}}"}' className="text-base md:text-sm font-mono" />
<Textarea
{...register("body_template")}
rows={3}
placeholder='{"event": "{{.Event}}"}'
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
</div>
</div>
)}
@@ -209,6 +265,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
rows={4}
placeholder="Evaluate the tool call and decide whether to allow or block it."
className="text-base md:text-sm"
disabled={isBuiltin}
/>
{errors.prompt_template && (
<p className="text-xs text-destructive">{errors.prompt_template.message}</p>
@@ -217,7 +274,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
<div className="space-y-1.5">
<Label>{t("form.model")}</Label>
<Controller control={control} name="model" render={({ field }) => (
<Select value={field.value ?? "haiku"} onValueChange={field.onChange}>
<Select value={field.value ?? "haiku"} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
@@ -237,6 +294,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
max={20}
{...register("max_invocations_per_turn", { valueAsNumber: true })}
className="text-base md:text-sm w-24"
disabled={isBuiltin}
/>
</div>
</div>
@@ -251,12 +309,13 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
min={100}
{...register("timeout_ms", { valueAsNumber: true })}
className="text-base md:text-sm"
disabled={isBuiltin}
/>
</div>
<div className="space-y-1.5">
<Label>{t("form.onTimeout")}</Label>
<Controller control={control} name="on_timeout" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
@@ -269,7 +328,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
</div>
{/* Priority + enabled */}
{/* Priority + enabled — enabled stays actionable even on builtin rows. */}
<div className="flex items-center gap-4">
<div className="flex-1 space-y-1.5">
<Label>{t("form.priority")}</Label>
@@ -279,6 +338,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
max={1000}
{...register("priority", { valueAsNumber: true })}
className="text-base md:text-sm"
disabled={isBuiltin}
/>
</div>
<div className="space-y-1.5">
@@ -297,7 +357,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
{t("form.cancel")}
</Button>
<Button onClick={handleSubmit(onFormSubmit)} disabled={isSubmitting}>
{t("form.save")}
{t(isBuiltin ? "form.saveToggle" : "form.save")}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import type { HookConfig } from "@/hooks/use-hooks";
import { SystemBadge } from "./system-badge";
interface HookListRowProps {
hook: HookConfig;
@@ -28,6 +29,7 @@ const HANDLER_COLORS: Record<string, string> = {
command: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
http: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
prompt: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
script: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
};
export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest }: HookListRowProps) {
@@ -56,6 +58,7 @@ export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest
<Badge variant="outline" className="text-2xs px-1 py-0">
{hook.scope}
</Badge>
{hook.source === "builtin" && <SystemBadge />}
</div>
{hook.matcher && (
<p className="mt-0.5 truncate text-xs text-muted-foreground font-mono">{hook.matcher}</p>
@@ -95,9 +98,10 @@ export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
onClick={onDelete}
title={t("actions.delete")}
disabled={hook.source === "builtin"}
title={hook.source === "builtin" ? t("form.builtinReadonly") : t("actions.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseLineCol } from "./script-editor";
// parseLineCol extracts {line, col} from goja compile-error strings. Format
// comes from goja parser/error.go: "%s: Line %d:%d %s". Keep this test
// permissive about the prefix but strict about the "Line N:M" shape because
// that's what the dispatcher surfaces verbatim to the UI.
describe("parseLineCol", () => {
it("parses goja format (anonymous): Line N:M message", () => {
const got = parseLineCol("(anonymous): Line 5:12 Unexpected token");
expect(got).toEqual({ line: 5, col: 12 });
});
it("parses multi-digit line + col", () => {
const got = parseLineCol("Line 123:456 Something went wrong");
expect(got).toEqual({ line: 123, col: 456 });
});
it("returns null for non-matching shape", () => {
expect(parseLineCol("some unstructured error")).toBeNull();
expect(parseLineCol("line 5 col 10 — wrong separator")).toBeNull();
});
it("returns null for empty / undefined input", () => {
expect(parseLineCol(undefined)).toBeNull();
expect(parseLineCol("")).toBeNull();
});
it("picks the first Line occurrence (multi-error case)", () => {
const got = parseLineCol("Line 2:3 first err; Line 9:9 later");
expect(got).toEqual({ line: 2, col: 3 });
});
});
@@ -0,0 +1,61 @@
import { useMemo } from "react";
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
import "prismjs/components/prism-javascript";
import { useTranslation } from "react-i18next";
interface ScriptEditorProps {
value: string;
onChange: (v: string) => void;
// Server-returned compile error. goja prints "(anonymous): Line N:M message"
// (see goja parser/error.go — "%s: Line %d:%d %s"). We extract the location
// with the regex below; when the shape doesn't match we surface the raw
// string unchanged.
error?: string;
readOnly?: boolean;
}
// parseLineCol extracts {line, col} from a goja compile-error string. Format
// is "... Line N:M ...". Any other shape returns null so the caller can fall
// back to rendering the raw message.
export function parseLineCol(err?: string): { line: number; col: number } | null {
if (!err) return null;
const m = err.match(/Line (\d+):(\d+)/);
if (!m || !m[1] || !m[2]) return null;
return { line: parseInt(m[1], 10), col: parseInt(m[2], 10) };
}
export function ScriptEditor({ value, onChange, error, readOnly }: ScriptEditorProps) {
const { t } = useTranslation("hooks");
const loc = useMemo(() => parseLineCol(error), [error]);
return (
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("form.scriptSource")}</label>
<div className="rounded-lg border bg-muted/30 overflow-hidden">
<Editor
value={value}
onValueChange={readOnly ? () => {} : onChange}
highlight={(code) => {
const lang = Prism.languages.javascript;
return lang ? Prism.highlight(code, lang, "javascript") : code;
}}
padding={12}
textareaClassName="outline-none"
readOnly={readOnly}
className="text-base md:text-sm font-mono min-h-[240px] leading-relaxed"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
</div>
{error ? (
<p className="text-xs text-destructive">
{loc
? t("form.compileError", { line: loc.line, col: loc.col })
: error}
</p>
) : (
<p className="text-xs text-muted-foreground">{t("form.scriptSchemaHelp")}</p>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import { useTranslation } from "react-i18next";
// SystemBadge marks a hook row as shipped by the binary (source='builtin').
// User cannot edit content — only toggle enabled. Placed next to the event
// chip in hook-list-row so it's visible without opening the detail panel.
export function SystemBadge() {
const { t } = useTranslation("hooks");
return (
<span
className="ml-1 inline-flex items-center rounded px-1.5 py-0.5 text-2xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
title={t("form.builtinReadonly")}
>
{t("table.systemBadge")}
</span>
);
}
+4
View File
@@ -57,6 +57,10 @@ function buildConfig(data: HookFormData): Record<string, unknown> {
body_template: data.body_template ?? "",
};
}
if (data.handler_type === "script") {
// Backend goja handler reads cfg.Config.source (Phase 03). Zod caps at 32 KiB.
return { source: data.script_source ?? "" };
}
// prompt
return {
prompt_template: data.prompt_template ?? "",
+12 -1
View File
@@ -13,7 +13,8 @@ export const HookEventEnum = z.enum([
// `command` deliberately absent: Wave 1 removes UI surface for it. Legacy Standard rows
// with handler_type="command" are auto-disabled at startup (Phase 07). Lite keeps running
// existing command hooks via dispatcher but cannot create new ones through the UI.
export const HookHandlerTypeEnum = z.enum(["http", "prompt"]);
// `script` added in Phase 06 (goja ES5.1 runtime; builtin PII redactor ships in Phase 05).
export const HookHandlerTypeEnum = z.enum(["script", "http", "prompt"]);
export const HookScopeEnum = z.enum(["global", "tenant", "agent"]);
@@ -36,6 +37,9 @@ export const hookFormSchema = z
prompt_template: z.string().optional(),
model: z.string().optional(),
max_invocations_per_turn: z.number().int().min(1).max(20).optional(),
// Script handler source (ES5.1 JavaScript). Cap mirrors backend 32 KiB
// enforcement in the goja handler.
script_source: z.string().max(32_768).optional(),
})
.superRefine((data, ctx) => {
// Validate regex if matcher is provided
@@ -67,6 +71,13 @@ export const hookFormSchema = z
});
}
}
if (data.handler_type === "script" && !data.script_source?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["script_source"],
message: "validation.scriptSourceRequired",
});
}
});
export type HookFormData = z.infer<typeof hookFormSchema>;