Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions cmd/gc/build_desired_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2108,23 +2108,60 @@ 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
}
}
}

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
Expand Down
84 changes: 84 additions & 0 deletions cmd/gc/build_desired_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
90 changes: 70 additions & 20 deletions cmd/gc/cmd_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<relWorkDir>/), not always at /workspace/.
Expand All @@ -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),
})
}
}
}

Expand Down Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions cmd/gc/cmd_start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions cmd/gc/template_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading