mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 16:13:53 +00:00
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:
1 parent
33d188f01f
commit
31eb953508
14 files changed
+292
-26
No files matched your search
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+31
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user