Files

184 lines
5.6 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/dop251/goja"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// compile returns a cached compiled program; fresh compile on cache miss or
// version change. strict=true gives stronger semantics (assigning to an
// undeclared variable throws instead of silently creating a global).
func (h *ScriptHandler) compile(cfg hooks.HookConfig, source string) (*goja.Program, error) {
key := progCacheKey{ID: cfg.ID, Version: cfg.Version}
if prog, ok := h.progCache.Get(key); ok {
return prog, nil
}
prog, err := goja.Compile(cfg.ID.String(), source, true)
if err != nil {
return nil, fmt.Errorf("compile: %w", err)
}
h.progCache.Add(key, prog)
return prog, nil
}
// bindEvent exposes a deep-frozen `event` global mirroring hooks.Event.
//
// The JSON round-trip disconnects the JS-side object from the Go map — goja
// would otherwise wrap ev.ToolInput as a live Go map view and script writes
// would propagate back, bypassing the source-tier capability check. One
// serialize/parse pair per exec is acceptable on the hook path.
//
// UUIDs serialize as strings; scripts observe `event.tenantId` / `event.agentId`
// as strings, not typed UUIDs.
func bindEvent(rt *goja.Runtime, ev hooks.Event) error {
viewable := map[string]any{
"hookEvent": string(ev.HookEvent),
"sessionId": ev.SessionID,
"tenantId": ev.TenantID.String(),
"agentId": ev.AgentID.String(),
"toolName": ev.ToolName,
"toolInput": ev.ToolInput,
"rawInput": ev.RawInput,
"depth": ev.Depth,
"eventId": ev.EventID,
}
// PostModelResponse-specific fields
if ev.HookEvent == hooks.EventPostModelResponse {
viewable["modelResponse"] = ev.ModelResponse
viewable["thinking"] = ev.Thinking
toolCalls := make([]map[string]any, len(ev.ToolCalls))
for i, tc := range ev.ToolCalls {
toolCalls[i] = map[string]any{
"id": tc.ID,
"name": tc.Name,
"arguments": tc.Arguments,
"metadata": tc.Metadata,
"parseError": tc.ParseError,
}
}
viewable["toolCalls"] = toolCalls
}
b, err := json.Marshal(viewable)
if err != nil {
return fmt.Errorf("bindEvent marshal: %w", err)
}
// Parse in JS so nested objects become native JS objects (not Go wrappers),
// then recursively Object.freeze every level to block in-place mutation.
script := `
var event = ` + string(b) + `;
(function deepFreeze(o) {
if (o === null || typeof o !== 'object') return;
Object.freeze(o);
for (var k in o) {
if (Object.prototype.hasOwnProperty.call(o, k)) deepFreeze(o[k]);
}
})(event);
`
_, err = rt.RunString(script)
return err
}
// captureStdout binds `console.log` / `console.error` to a truncating buffer.
// Once the buffer reaches MaxStdoutBytes, a single `... truncated` marker is
// written and further writes are dropped so a runaway print loop cannot
// exhaust memory.
func captureStdout(rt *goja.Runtime, buf *strings.Builder) {
truncated := false
write := func(level string, args ...any) {
if truncated {
return
}
parts := make([]string, 0, len(args)+1)
parts = append(parts, level+":")
for _, a := range args {
parts = append(parts, fmt.Sprint(a))
}
line := strings.Join(parts, " ") + "\n"
remaining := MaxStdoutBytes - buf.Len()
if len(line) > remaining {
if remaining > 0 {
line = line[:remaining] + "... truncated\n"
} else {
line = "... truncated\n"
}
truncated = true
}
buf.WriteString(line)
}
console := rt.NewObject()
_ = console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, a := range call.Arguments {
args[i] = a.Export()
}
write("log", args...)
return goja.Undefined()
})
_ = console.Set("error", func(call goja.FunctionCall) goja.Value {
args := make([]any, len(call.Arguments))
for i, a := range call.Arguments {
args[i] = a.Export()
}
write("error", args...)
return goja.Undefined()
})
_ = rt.Set("console", console)
}
// parseReturn enforces the
// `{decision, reason?, additionalContext?, updatedInput?}` contract.
// Any deviation (non-object, missing field, unknown decision string) maps to
// DecisionError with a descriptive error.
func parseReturn(rt *goja.Runtime, v goja.Value) (hooks.Decision, string, string, map[string]any, error) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return hooks.DecisionError, "", "", nil, errors.New("script return: must be object with decision")
}
obj := v.ToObject(rt)
if obj == nil {
return hooks.DecisionError, "", "", nil, errors.New("script return: must be object")
}
decRaw := obj.Get("decision")
if decRaw == nil || goja.IsUndefined(decRaw) {
return hooks.DecisionError, "", "", nil, errors.New("script return: missing decision field")
}
decStr, ok := decRaw.Export().(string)
if !ok {
return hooks.DecisionError, "", "", nil, errors.New("script return: decision must be string")
}
dec := hooks.Decision(decStr)
switch dec {
case hooks.DecisionAllow, hooks.DecisionBlock, hooks.DecisionAsk, hooks.DecisionDefer:
// ok
default:
return hooks.DecisionError, "", "", nil, fmt.Errorf("script return: invalid decision %q", decStr)
}
var reason string
if r := obj.Get("reason"); r != nil && !goja.IsUndefined(r) {
if s, ok := r.Export().(string); ok {
reason = s
}
}
var additionalContext string
if a := obj.Get("additionalContext"); a != nil && !goja.IsUndefined(a) {
if s, ok := a.Export().(string); ok {
additionalContext = s
}
}
var updated map[string]any
if u := obj.Get("updatedInput"); u != nil && !goja.IsUndefined(u) {
if m, ok := u.Export().(map[string]any); ok {
updated = m
}
}
return dec, reason, additionalContext, updated, nil
}