diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index 40c8029785..f6ccc78dc6 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -2108,16 +2108,20 @@ func prepareTemplateResolution(bp *agentBuildParams, cfgAgent *config.Agent, qua if bp == nil || cfgAgent == nil { return } - if ih := config.ResolveInstallHooks(cfgAgent, bp.workspace); len(ih) > 0 { - workDir, err := workdirutil.ResolveWorkDirPathStrict(bp.cityPath, bp.cityName, qualifiedName, *cfgAgent, bp.rigs) - if err != nil { - return - } - workDir, err = resolveAgentDir(bp.cityPath, workDir) - if err != nil { + resolved, err := config.ResolveProvider(cfgAgent, bp.workspace, bp.providers, bp.lookPath) + if err != nil { + return + } + workDir, err := resolveConfiguredWorkDir(bp.cityPath, bp.cityName, qualifiedName, cfgAgent, bp.rigs) + if err != nil { + if stderr != nil { fmt.Fprintf(stderr, "agent %q: workdir: %v\n", qualifiedName, err) //nolint:errcheck - return } + return + } + rigName := sessionSetupContextForAgent(bp.cityPath, bp.cityName, qualifiedName, cfgAgent, bp.rigs).Rig + materializeProviderOverlaysBeforeFingerprint(bp, cfgAgent, resolved, qualifiedName, rigName, workDir, stderr) + if ih := config.ResolveInstallHooks(cfgAgent, bp.workspace); len(ih) > 0 { resolver := func(name string) string { return config.BuiltinFamily(name, bp.providers) } if hErr := hooks.InstallWithResolver(bp.fs, bp.cityPath, workDir, ih, resolver); hErr != nil { fmt.Fprintf(stderr, "agent %q: hooks: %v\n", qualifiedName, hErr) //nolint:errcheck @@ -2125,6 +2129,39 @@ func prepareTemplateResolution(bp *agentBuildParams, cfgAgent *config.Agent, qua } } +func materializeProviderOverlaysBeforeFingerprint( + bp *agentBuildParams, + cfgAgent *config.Agent, + resolved *config.ResolvedProvider, + qualifiedName string, + rigName string, + workDir string, + stderr io.Writer, +) { + if bp == nil || cfgAgent == nil || resolved == nil || workDir == "" { + return + } + if stderr == nil { + stderr = io.Discard + } + installHooks := config.ResolveInstallHooks(cfgAgent, bp.workspace) + overlayProviders := runtime.OverlayProviderNamesFromParts( + resolvedProviderLaunchFamily(resolved), + strings.TrimSpace(resolved.Name), + installHooks, + ) + for _, overlayDir := range effectiveOverlayDirs(bp.packOverlayDirs, bp.rigOverlayDirs, rigName) { + if err := runtime.StageProviderOverlayDir(overlayDir, workDir, overlayProviders, stderr); err != nil { + fmt.Fprintf(stderr, "agent %q: pack overlay %q: %v\n", qualifiedName, overlayDir, err) //nolint:errcheck + } + } + if overlayDir := resolveOverlayDir(cfgAgent.OverlayDir, bp.cityPath); overlayDir != "" { + if err := runtime.StageProviderOverlayDir(overlayDir, workDir, overlayProviders, stderr); err != nil { + fmt.Fprintf(stderr, "agent %q: overlay %q: %v\n", qualifiedName, overlayDir, err) //nolint:errcheck + } + } +} + func resolveTemplatePrepared(bp *agentBuildParams, cfgAgent *config.Agent, qualifiedName string, fpExtra map[string]string) (TemplateParams, error) { if err := validateAgentSessionTransportForBuild(bp, cfgAgent, qualifiedName); err != nil { return TemplateParams{}, err diff --git a/cmd/gc/build_desired_state_test.go b/cmd/gc/build_desired_state_test.go index 68fdcae808..f5ce9707ae 100644 --- a/cmd/gc/build_desired_state_test.go +++ b/cmd/gc/build_desired_state_test.go @@ -1319,6 +1319,90 @@ func TestBuildDesiredState_InstallsGeminiHooksBeforeFingerprinting(t *testing.T) } } +func TestBuildDesiredState_MaterializesHookOverlaysBeforeFingerprinting(t *testing.T) { + cityPath := t.TempDir() + packOverlay := filepath.Join(cityPath, "packs", "core", "overlay") + overlayHook := filepath.Join(packOverlay, "per-provider", "gemini", ".gemini", "settings.json") + workHook := filepath.Join(cityPath, "worker", ".gemini", "settings.json") + for _, dir := range []string{filepath.Dir(overlayHook), filepath.Dir(workHook)} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", dir, err) + } + } + // Same semantic hook document as overlayHook, but intentionally not in the + // canonical JSON shape that runtime overlay staging writes. + nonCanonicalHook := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"gc prime --hook --hook-format gemini"}],"matcher":""}]},"tools":{"shell":{"enableInteractiveShell":false}}}` + "\n") + canonicalOverlayHook := []byte(`{ + "tools": { + "shell": { + "enableInteractiveShell": false + } + }, + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gc prime --hook --hook-format gemini" + } + ] + } + ] + } +} +`) + if err := os.WriteFile(workHook, nonCanonicalHook, 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", workHook, err) + } + if err := os.WriteFile(overlayHook, canonicalOverlayHook, 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", overlayHook, err) + } + + cfg := &config.City{ + Workspace: config.Workspace{Name: "test-city", Provider: "test"}, + Providers: map[string]config.ProviderSpec{ + "test": {Command: "echo", PromptMode: "none"}, + }, + PackOverlayDirs: []string{packOverlay}, + Agents: []config.Agent{{ + Name: "probe", + StartCommand: "true", + MaxActiveSessions: intPtr(1), + ScaleCheck: "echo 1", + WorkDir: "worker", + InstallAgentHooks: []string{"gemini"}, + }}, + } + + first := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), nil, io.Discard) + var firstTP TemplateParams + for _, tp := range first.State { + firstTP = tp + } + firstCfg := templateParamsToConfig(firstTP) + if firstCfg.WorkDir == "" { + t.Fatalf("first desired state missing runtime config: %#v", first.State) + } + firstHash := runtime.CoreFingerprint(firstCfg) + + if err := runtime.StageSessionWorkDir(firstCfg); err != nil { + t.Fatalf("StageSessionWorkDir: %v", err) + } + + second := buildDesiredState("test-city", cityPath, time.Now().UTC(), cfg, runtime.NewFake(), nil, io.Discard) + var secondTP TemplateParams + for _, tp := range second.State { + secondTP = tp + } + secondCfg := templateParamsToConfig(secondTP) + if got := runtime.CoreFingerprint(secondCfg); got != firstHash { + t.Fatalf("core fingerprint changed after runtime overlay materialization: first=%s second=%s firstCopyFiles=%#v secondCopyFiles=%#v", + firstHash, got, firstCfg.CopyFiles, secondCfg.CopyFiles) + } +} + func TestBuildDesiredState_IncludesImportedAlwaysNamedSessions(t *testing.T) { cityPath := t.TempDir() rigPath := filepath.Join(cityPath, "repo") diff --git a/cmd/gc/cmd_start.go b/cmd/gc/cmd_start.go index dcaccef0e3..6e4b528a7f 100644 --- a/cmd/gc/cmd_start.go +++ b/cmd/gc/cmd_start.go @@ -860,10 +860,9 @@ func claudeSettingsSource(cityPath string) (src, rel string) { // (bind-mount), but the extra entries are harmless. // // Claude's city-level .gc/settings.json is staged here because settingsArgs -// points --settings at the city-root path. All other provider hook files -// ship via the core pack overlay and flow through PackOverlayDirs staging, -// so they are not handled here. -func stageHookFiles(copyFiles []runtime.CopyEntry, cityPath, workDir string) []runtime.CopyEntry { +// points --settings at the city-root path. Workdir hook files are included +// only when they match the resolved provider or requested install-hook slots. +func stageHookFiles(copyFiles []runtime.CopyEntry, cityPath, workDir string, hookProviders []string) []runtime.CopyEntry { // Compute the relative path from cityPath to workDir so that // container-side RelDst places files under the agent's WorkingDir // (/workspace//), not always at /workspace/. @@ -875,23 +874,20 @@ func stageHookFiles(copyFiles []runtime.CopyEntry, cityPath, workDir string) []r } } + providerSet := hookProviderSet(hookProviders) // workDir-based hooks: gemini, codex, opencode, copilot, cursor, pi, omp. - for _, rel := range []string{ - path.Join(".gemini", "settings.json"), - path.Join(".codex", "hooks.json"), - path.Join(".opencode", "plugins", "gascity.js"), - path.Join(".github", "hooks", "gascity.json"), - path.Join(".github", "copilot-instructions.md"), - path.Join(".cursor", "hooks.json"), - path.Join(".pi", "extensions", "gc-hooks.js"), - path.Join(".omp", "hooks", "gc-hook.ts"), - } { - abs := filepath.Join(workDir, rel) - if _, err := os.Stat(abs); err == nil { - copyFiles = append(copyFiles, runtime.CopyEntry{ - Src: abs, RelDst: path.Join(relWorkDir, rel), - Probed: true, ContentHash: runtime.HashPathContent(abs), - }) + for _, provider := range orderedWorkDirHookProviders { + if !providerSet[provider.name] { + continue + } + for _, rel := range provider.relPaths { + abs := filepath.Join(workDir, rel) + if _, err := os.Stat(abs); err == nil { + copyFiles = append(copyFiles, runtime.CopyEntry{ + Src: abs, RelDst: path.Join(relWorkDir, rel), + Probed: true, ContentHash: runtime.HashPathContent(abs), + }) + } } } @@ -922,6 +918,60 @@ func stageHookFiles(copyFiles []runtime.CopyEntry, cityPath, workDir string) []r return copyFiles } +type workDirHookProvider struct { + name string + relPaths []string +} + +var orderedWorkDirHookProviders = []workDirHookProvider{ + {name: "gemini", relPaths: []string{path.Join(".gemini", "settings.json")}}, + {name: "codex", relPaths: []string{path.Join(".codex", "hooks.json")}}, + {name: "opencode", relPaths: []string{path.Join(".opencode", "plugins", "gascity.js")}}, + {name: "copilot", relPaths: []string{ + path.Join(".github", "hooks", "gascity.json"), + path.Join(".github", "copilot-instructions.md"), + }}, + {name: "cursor", relPaths: []string{path.Join(".cursor", "hooks.json")}}, + {name: "pi", relPaths: []string{path.Join(".pi", "extensions", "gc-hooks.js")}}, + {name: "omp", relPaths: []string{path.Join(".omp", "hooks", "gc-hook.ts")}}, +} + +func hookFileProvidersForResolved(resolved *config.ResolvedProvider, installHooks []string, providers map[string]config.ProviderSpec) []string { + var out []string + appendProvider := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + for _, existing := range out { + if existing == name { + return + } + } + out = append(out, name) + } + if resolved != nil { + appendProvider(resolvedProviderLaunchFamily(resolved)) + appendProvider(resolved.Name) + } + for _, hook := range installHooks { + appendProvider(hook) + appendProvider(config.BuiltinFamily(hook, providers)) + } + return out +} + +func hookProviderSet(providers []string) map[string]bool { + out := make(map[string]bool, len(providers)) + for _, provider := range providers { + provider = strings.TrimSpace(provider) + if provider != "" { + out[provider] = true + } + } + return out +} + // resolveAgentDirPath returns the absolute filesystem path for an agent dir // spec. Empty dir defaults to cityPath. Relative paths resolve against // cityPath. This helper is pure and does not create directories. diff --git a/cmd/gc/cmd_start_test.go b/cmd/gc/cmd_start_test.go index b66a33cb73..aeaa3fcfb3 100644 --- a/cmd/gc/cmd_start_test.go +++ b/cmd/gc/cmd_start_test.go @@ -474,7 +474,7 @@ func TestStageHookFilesIncludesCanonicalClaudeHook(t *testing.T) { t.Fatalf("WriteFile(%q): %v", settingsPath, err) } - got := stageHookFiles(nil, cityDir, workDir) + got := stageHookFiles(nil, cityDir, workDir, []string{"claude"}) for _, entry := range got { // City-root-relative hook: no workDir prefix in RelDst. if entry.RelDst == path.Join(".gc", "settings.json") { @@ -504,7 +504,7 @@ func TestStageHookFilesFallsBackToLegacyClaudeHook(t *testing.T) { t.Fatalf("WriteFile(%q): %v", hookPath, err) } - got := stageHookFiles(nil, cityDir, workDir) + got := stageHookFiles(nil, cityDir, workDir, []string{"claude"}) for _, entry := range got { if entry.RelDst == path.Join("hooks", "claude.json") { if entry.Src != hookPath { @@ -534,7 +534,7 @@ func TestStageHookFilesDoesNotStageClaudeSkillsDir(t *testing.T) { t.Fatalf("WriteFile(%q): %v", skillPath, err) } - got := stageHookFiles(nil, cityDir, workDir) + got := stageHookFiles(nil, cityDir, workDir, []string{"claude"}) wantRelDst := path.Join("worker", ".claude", "skills") for _, entry := range got { if entry.RelDst == wantRelDst { @@ -543,6 +543,25 @@ func TestStageHookFilesDoesNotStageClaudeSkillsDir(t *testing.T) { } } +func TestStageHookFilesSkipsUnrequestedWorkDirHooks(t *testing.T) { + cityDir := filepath.Join(t.TempDir(), "city") + workDir := filepath.Join(cityDir, "worker") + hookPath := filepath.Join(workDir, ".gemini", "settings.json") + if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", hookPath, err) + } + if err := os.WriteFile(hookPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", hookPath, err) + } + + got := stageHookFiles(nil, cityDir, workDir, []string{"claude"}) + for _, entry := range got { + if entry.RelDst == path.Join("worker", ".gemini", "settings.json") { + t.Fatalf("stageHookFiles() staged unrequested hook %q", entry.Src) + } + } +} + func TestConfiguredRigNameMatchesRigByPathWithoutCreatingDirs(t *testing.T) { cityPath := t.TempDir() rigRoot := filepath.Join(cityPath, "repos", "demo") diff --git a/cmd/gc/template_resolve.go b/cmd/gc/template_resolve.go index a4f868871e..90434b77c5 100644 --- a/cmd/gc/template_resolve.go +++ b/cmd/gc/template_resolve.go @@ -171,6 +171,7 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName command = command + " " + shellquote.Join(defaultArgs) } providerFamily := resolvedProviderLaunchFamily(resolved) + installHooks := config.ResolveInstallHooks(cfgAgent, p.workspace) sa, err := ensureClaudeSettingsArgs(p.fs, p.cityPath, providerFamily, p.stderr) if err != nil { return TemplateParams{}, fmt.Errorf("agent %q: %w", qualifiedName, err) @@ -192,7 +193,7 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName Probed: true, ContentHash: runtime.HashPathContent(scriptsDir), }) } - copyFiles = stageHookFiles(copyFiles, p.cityPath, workDir) + copyFiles = stageHookFiles(copyFiles, p.cityPath, workDir, hookFileProvidersForResolved(resolved, installHooks, p.providers)) // Step 6: Compute session name. // Uses bead-derived naming ("s-{beadID}") when a bead store is available, @@ -522,7 +523,7 @@ func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName SessionLive: expandedLive, ProviderName: resolvedProviderLaunchFamily(resolved), ProviderOverlayName: strings.TrimSpace(resolved.Name), - InstallAgentHooks: config.ResolveInstallHooks(cfgAgent, p.workspace), + InstallAgentHooks: installHooks, PackOverlayDirs: effectiveOverlayDirs(p.packOverlayDirs, p.rigOverlayDirs, rigName), OverlayDir: overlayDir, CopyFiles: copyFiles,