Skip to content

Commit 367277d

Browse files
committed
fix(runtime): 按运行时 workdir 动态解析 repo hooks 并补齐覆盖
1 parent 26ce55f commit 367277d

5 files changed

Lines changed: 337 additions & 21 deletions

File tree

internal/runtime/hooks_integration_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package runtime
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"strings"
78
"sync"
89
"testing"
@@ -151,6 +152,9 @@ func TestExecuteOneToolCallTriggersAfterToolResultHookWithoutMutatingResult(t *t
151152
if got := metadata["result_content_preview"]; got != "ok" {
152153
t.Fatalf("result_content_preview = %#v, want %q", got, "ok")
153154
}
155+
if got := strings.TrimSpace(fmt.Sprintf("%v", metadata["workdir"])); got == "" {
156+
t.Fatalf("expected workdir metadata in after_tool_result hook, got %#v", metadata["workdir"])
157+
}
154158
}
155159

156160
func TestExecuteOneToolCallCanceledStillTriggersAfterToolResultHook(t *testing.T) {

internal/runtime/repo_hooks.go

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010
"strings"
11+
"sync"
1112
"time"
1213

1314
"gopkg.in/yaml.v3"
@@ -51,6 +52,16 @@ type trustDecision struct {
5152
InvalidReason string
5253
}
5354

55+
// dynamicRepoHookExecutor 按运行时 workdir 惰性解析 repo hooks,避免绑定启动时 cfg.Workdir。
56+
type dynamicRepoHookExecutor struct {
57+
service *Service
58+
hooksCfg config.RuntimeHooksConfig
59+
fallbackWorkdir string
60+
61+
mu sync.RWMutex
62+
cache map[string]HookExecutor
63+
}
64+
5465
// buildUserHookExecutor 根据 runtime.hooks.items 构建 user hooks 执行器。
5566
func buildUserHookExecutor(
5667
service *Service,
@@ -91,10 +102,56 @@ func buildRepoHookExecutor(
91102
cfg config.Config,
92103
hooksCfg config.RuntimeHooksConfig,
93104
) (HookExecutor, error) {
94-
workspace := strings.TrimSpace(cfg.Workdir)
105+
return &dynamicRepoHookExecutor{
106+
service: service,
107+
hooksCfg: hooksCfg,
108+
fallbackWorkdir: strings.TrimSpace(cfg.Workdir),
109+
cache: make(map[string]HookExecutor),
110+
}, nil
111+
}
112+
113+
// Run 在每个 hook point 调用时按输入 workdir 解析对应 workspace 的 repo hooks 执行器。
114+
func (e *dynamicRepoHookExecutor) Run(
115+
ctx context.Context,
116+
point runtimehooks.HookPoint,
117+
input runtimehooks.HookContext,
118+
) runtimehooks.RunOutput {
119+
if e == nil {
120+
return runtimehooks.RunOutput{}
121+
}
122+
workspace := resolveHookWorkdir(input, e.fallbackWorkdir)
123+
workspace = strings.TrimSpace(workspace)
95124
if workspace == "" {
96-
return nil, nil
125+
return runtimehooks.RunOutput{}
126+
}
127+
normalizedWorkspace, err := normalizeTrustedWorkspacePath(workspace)
128+
if err != nil {
129+
return runtimehooks.RunOutput{}
97130
}
131+
132+
e.mu.RLock()
133+
executor, ok := e.cache[normalizedWorkspace]
134+
e.mu.RUnlock()
135+
if ok {
136+
return runHookExecutorSafely(executor, ctx, point, input)
137+
}
138+
139+
loaded, loadErr := buildRepoHookExecutorForWorkspace(e.service, workspace, e.hooksCfg)
140+
if loadErr != nil {
141+
return runtimehooks.RunOutput{}
142+
}
143+
e.mu.Lock()
144+
e.cache[normalizedWorkspace] = loaded
145+
e.mu.Unlock()
146+
return runHookExecutorSafely(loaded, ctx, point, input)
147+
}
148+
149+
// buildRepoHookExecutorForWorkspace 在指定 workspace 受信任时构建 repo hooks 执行器。
150+
func buildRepoHookExecutorForWorkspace(
151+
service *Service,
152+
workspace string,
153+
hooksCfg config.RuntimeHooksConfig,
154+
) (HookExecutor, error) {
98155
hooksPath, found, err := resolveRepoHooksPath(workspace)
99156
if err != nil {
100157
return nil, err

internal/runtime/repo_hooks_test.go

Lines changed: 261 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,10 @@ hooks:
243243
t.Fatalf("write trust store: %v", err)
244244
}
245245

246-
cfg := *config.StaticDefaults()
247-
cfg.Workdir = workspace
248246
service := &Service{events: make(chan RuntimeEvent, 64)}
249-
exec, err := buildRepoHookExecutor(service, cfg, config.StaticDefaults().Runtime.Hooks)
247+
exec, err := buildRepoHookExecutorForWorkspace(service, workspace, config.StaticDefaults().Runtime.Hooks)
250248
if err != nil {
251-
t.Fatalf("buildRepoHookExecutor() error = %v", err)
249+
t.Fatalf("buildRepoHookExecutorForWorkspace() error = %v", err)
252250
}
253251
if exec != nil {
254252
t.Fatal("expected nil repo executor for untrusted workspace")
@@ -287,12 +285,10 @@ hooks:
287285
t.Fatalf("write hooks: %v", err)
288286
}
289287

290-
cfg := *config.StaticDefaults()
291-
cfg.Workdir = workspace
292288
service := &Service{events: make(chan RuntimeEvent, 64)}
293-
exec, err := buildRepoHookExecutor(service, cfg, config.StaticDefaults().Runtime.Hooks)
289+
exec, err := buildRepoHookExecutorForWorkspace(service, workspace, config.StaticDefaults().Runtime.Hooks)
294290
if err != nil {
295-
t.Fatalf("buildRepoHookExecutor() error = %v", err)
291+
t.Fatalf("buildRepoHookExecutorForWorkspace() error = %v", err)
296292
}
297293
if exec != nil {
298294
t.Fatal("expected nil repo executor when trust store is missing")
@@ -307,6 +303,91 @@ hooks:
307303
}
308304
}
309305

306+
func TestDynamicRepoHookExecutorResolvesByRunWorkdir(t *testing.T) {
307+
homeDir := t.TempDir()
308+
t.Setenv("HOME", homeDir)
309+
workspaceA := filepath.Join(homeDir, "workspace-a")
310+
workspaceB := filepath.Join(homeDir, "workspace-b")
311+
if err := os.MkdirAll(filepath.Join(workspaceA, ".neocode"), 0o755); err != nil {
312+
t.Fatalf("mkdir workspaceA hooks dir: %v", err)
313+
}
314+
if err := os.MkdirAll(filepath.Join(workspaceB, ".neocode"), 0o755); err != nil {
315+
t.Fatalf("mkdir workspaceB hooks dir: %v", err)
316+
}
317+
if err := os.WriteFile(filepath.Join(workspaceA, ".neocode", "hooks.yaml"), []byte(`
318+
hooks:
319+
items:
320+
- id: repo-a
321+
point: before_tool_call
322+
scope: repo
323+
kind: builtin
324+
mode: sync
325+
handler: add_context_note
326+
params:
327+
note: repo-note-a
328+
`), 0o644); err != nil {
329+
t.Fatalf("write workspaceA hooks: %v", err)
330+
}
331+
if err := os.WriteFile(filepath.Join(workspaceB, ".neocode", "hooks.yaml"), []byte(`
332+
hooks:
333+
items:
334+
- id: repo-b
335+
point: before_tool_call
336+
scope: repo
337+
kind: builtin
338+
mode: sync
339+
handler: add_context_note
340+
params:
341+
note: repo-note-b
342+
`), 0o644); err != nil {
343+
t.Fatalf("write workspaceB hooks: %v", err)
344+
}
345+
346+
storePath := resolveTrustedWorkspacesPath()
347+
if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil {
348+
t.Fatalf("mkdir trust store dir: %v", err)
349+
}
350+
rawStore, err := json.Marshal(trustedWorkspaceStore{
351+
Version: repoHooksTrustStoreVersion,
352+
Workspaces: []string{workspaceA, workspaceB},
353+
})
354+
if err != nil {
355+
t.Fatalf("marshal trust store: %v", err)
356+
}
357+
if err := os.WriteFile(storePath, rawStore, 0o644); err != nil {
358+
t.Fatalf("write trust store: %v", err)
359+
}
360+
361+
cfg := *config.StaticDefaults()
362+
cfg.Workdir = workspaceA
363+
service := &Service{events: make(chan RuntimeEvent, 64)}
364+
repoExecutor, err := buildRepoHookExecutor(service, cfg, config.StaticDefaults().Runtime.Hooks)
365+
if err != nil {
366+
t.Fatalf("buildRepoHookExecutor() error = %v", err)
367+
}
368+
if repoExecutor == nil {
369+
t.Fatal("expected dynamic repo executor")
370+
}
371+
372+
run := func(workdir string) runtimehooks.RunOutput {
373+
return repoExecutor.Run(context.Background(), runtimehooks.HookPointBeforeToolCall, runtimehooks.HookContext{
374+
Metadata: map[string]any{
375+
"tool_name": "bash",
376+
"workdir": workdir,
377+
},
378+
})
379+
}
380+
381+
first := run(workspaceA)
382+
if len(first.Results) != 1 || first.Results[0].Message != "repo-note-a" {
383+
t.Fatalf("workspaceA output = %+v, want repo-note-a", first.Results)
384+
}
385+
second := run(workspaceB)
386+
if len(second.Results) != 1 || second.Results[0].Message != "repo-note-b" {
387+
t.Fatalf("workspaceB output = %+v, want repo-note-b", second.Results)
388+
}
389+
}
390+
310391
func containsRuntimeEventType(events []RuntimeEvent, target EventType) bool {
311392
for _, event := range events {
312393
if event.Type == target {
@@ -315,3 +396,175 @@ func containsRuntimeEventType(events []RuntimeEvent, target EventType) bool {
315396
}
316397
return false
317398
}
399+
400+
func TestValidateRepoHookItemBranches(t *testing.T) {
401+
base := config.RuntimeHookItemConfig{
402+
ID: "repo-hook",
403+
Point: "before_tool_call",
404+
Scope: "repo",
405+
Kind: "builtin",
406+
Mode: "sync",
407+
Handler: "add_context_note",
408+
TimeoutSec: 2,
409+
FailurePolicy: "warn_only",
410+
Params: map[string]any{"note": "x"},
411+
}
412+
413+
if err := validateRepoHookItem(base); err != nil {
414+
t.Fatalf("validateRepoHookItem(valid) error = %v", err)
415+
}
416+
417+
cases := []struct {
418+
name string
419+
edit func(*config.RuntimeHookItemConfig)
420+
}{
421+
{name: "missing id", edit: func(item *config.RuntimeHookItemConfig) { item.ID = "" }},
422+
{name: "bad point", edit: func(item *config.RuntimeHookItemConfig) { item.Point = "session_start" }},
423+
{name: "bad scope", edit: func(item *config.RuntimeHookItemConfig) { item.Scope = "user" }},
424+
{name: "bad kind", edit: func(item *config.RuntimeHookItemConfig) { item.Kind = "command" }},
425+
{name: "bad mode", edit: func(item *config.RuntimeHookItemConfig) { item.Mode = "async" }},
426+
{name: "bad timeout", edit: func(item *config.RuntimeHookItemConfig) { item.TimeoutSec = 0 }},
427+
{name: "bad policy", edit: func(item *config.RuntimeHookItemConfig) { item.FailurePolicy = "deny" }},
428+
{name: "bad handler", edit: func(item *config.RuntimeHookItemConfig) { item.Handler = "unknown" }},
429+
{
430+
name: "warn_on_tool_call missing target",
431+
edit: func(item *config.RuntimeHookItemConfig) {
432+
item.Handler = "warn_on_tool_call"
433+
item.Params = map[string]any{}
434+
},
435+
},
436+
}
437+
438+
for _, tc := range cases {
439+
tc := tc
440+
t.Run(tc.name, func(t *testing.T) {
441+
item := base.Clone()
442+
tc.edit(&item)
443+
if err := validateRepoHookItem(item); err == nil {
444+
t.Fatalf("validateRepoHookItem(%s) expected error", tc.name)
445+
}
446+
})
447+
}
448+
}
449+
450+
func TestRuntimeHasWarnOnToolCallTargetsBranches(t *testing.T) {
451+
cases := []struct {
452+
name string
453+
params map[string]any
454+
want bool
455+
}{
456+
{name: "nil", params: nil, want: false},
457+
{name: "tool_name", params: map[string]any{"tool_name": "bash"}, want: true},
458+
{name: "tool_name blank", params: map[string]any{"tool_name": " "}, want: false},
459+
{name: "tool_names", params: map[string]any{"tool_names": []any{"bash"}}, want: true},
460+
{name: "tool_names blank", params: map[string]any{"tool_names": []any{" "}}, want: false},
461+
}
462+
for _, tc := range cases {
463+
tc := tc
464+
t.Run(tc.name, func(t *testing.T) {
465+
if got := runtimeHasWarnOnToolCallTargets(tc.params); got != tc.want {
466+
t.Fatalf("runtimeHasWarnOnToolCallTargets() = %v, want %v", got, tc.want)
467+
}
468+
})
469+
}
470+
}
471+
472+
func TestResolveRepoHooksPathBranches(t *testing.T) {
473+
workspace := t.TempDir()
474+
hooksPath := filepath.Join(workspace, ".neocode", "hooks.yaml")
475+
476+
path, found, err := resolveRepoHooksPath(workspace)
477+
if err != nil || found || path != hooksPath {
478+
t.Fatalf("resolveRepoHooksPath(missing) = (%q,%v,%v), want (%q,false,nil)", path, found, err, hooksPath)
479+
}
480+
481+
if err := os.MkdirAll(hooksPath, 0o755); err != nil {
482+
t.Fatalf("mkdir hooks dir: %v", err)
483+
}
484+
_, _, err = resolveRepoHooksPath(workspace)
485+
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "directory") {
486+
t.Fatalf("resolveRepoHooksPath(directory) error = %v, want directory error", err)
487+
}
488+
}
489+
490+
func TestNormalizeTrustedWorkspacePathBranches(t *testing.T) {
491+
if _, err := normalizeTrustedWorkspacePath(""); err == nil {
492+
t.Fatal("expected empty path error")
493+
}
494+
if _, err := normalizeTrustedWorkspacePath("relative/path"); err == nil {
495+
t.Fatal("expected relative path error")
496+
}
497+
workspace := t.TempDir()
498+
got, err := normalizeTrustedWorkspacePath(workspace)
499+
if err != nil {
500+
t.Fatalf("normalizeTrustedWorkspacePath(abs) error = %v", err)
501+
}
502+
if strings.TrimSpace(got) == "" {
503+
t.Fatal("normalized workspace path should not be empty")
504+
}
505+
}
506+
507+
func TestDynamicRepoHookExecutorCachesWorkspaceResult(t *testing.T) {
508+
homeDir := t.TempDir()
509+
t.Setenv("HOME", homeDir)
510+
workspace := filepath.Join(homeDir, "workspace")
511+
if err := os.MkdirAll(filepath.Join(workspace, ".neocode"), 0o755); err != nil {
512+
t.Fatalf("mkdir hooks dir: %v", err)
513+
}
514+
if err := os.WriteFile(filepath.Join(workspace, ".neocode", "hooks.yaml"), []byte(`
515+
hooks:
516+
items:
517+
- id: repo-cache
518+
point: before_tool_call
519+
scope: repo
520+
kind: builtin
521+
mode: sync
522+
handler: add_context_note
523+
params:
524+
note: repo-note-cache
525+
`), 0o644); err != nil {
526+
t.Fatalf("write hooks file: %v", err)
527+
}
528+
storePath := resolveTrustedWorkspacesPath()
529+
if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil {
530+
t.Fatalf("mkdir trust store dir: %v", err)
531+
}
532+
rawStore, err := json.Marshal(trustedWorkspaceStore{
533+
Version: repoHooksTrustStoreVersion,
534+
Workspaces: []string{workspace},
535+
})
536+
if err != nil {
537+
t.Fatalf("marshal trust store: %v", err)
538+
}
539+
if err := os.WriteFile(storePath, rawStore, 0o644); err != nil {
540+
t.Fatalf("write trust store: %v", err)
541+
}
542+
543+
cfg := *config.StaticDefaults()
544+
cfg.Workdir = workspace
545+
service := &Service{events: make(chan RuntimeEvent, 64)}
546+
exec, err := buildRepoHookExecutor(service, cfg, config.StaticDefaults().Runtime.Hooks)
547+
if err != nil {
548+
t.Fatalf("buildRepoHookExecutor() error = %v", err)
549+
}
550+
dynamic, ok := exec.(*dynamicRepoHookExecutor)
551+
if !ok {
552+
t.Fatalf("expected dynamicRepoHookExecutor, got %T", exec)
553+
}
554+
555+
input := runtimehooks.HookContext{Metadata: map[string]any{"workdir": workspace}}
556+
first := dynamic.Run(context.Background(), runtimehooks.HookPointBeforeToolCall, input)
557+
second := dynamic.Run(context.Background(), runtimehooks.HookPointBeforeToolCall, input)
558+
if len(first.Results) != 1 || len(second.Results) != 1 {
559+
t.Fatalf("unexpected cached run outputs: first=%+v second=%+v", first.Results, second.Results)
560+
}
561+
if first.Results[0].Message != "repo-note-cache" || second.Results[0].Message != "repo-note-cache" {
562+
t.Fatalf("unexpected note messages: first=%+v second=%+v", first.Results, second.Results)
563+
}
564+
dynamic.mu.RLock()
565+
cacheSize := len(dynamic.cache)
566+
dynamic.mu.RUnlock()
567+
if cacheSize != 1 {
568+
t.Fatalf("cache size = %d, want 1", cacheSize)
569+
}
570+
}

0 commit comments

Comments
 (0)